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 batch_max_requests: Option<u64>,
105 #[serde(default)]
108 pub batch_max_input_bytes: Option<u64>,
109 #[serde(default)]
112 pub batch_result_retention_days: Option<u32>,
113 #[serde(default)]
116 pub batch_result_ordering: Option<String>,
117 #[serde(default)]
120 pub batch_partial_failure: Option<String>,
121 #[serde(default)]
124 pub batch_cancellation: Option<String>,
125 #[serde(default)]
128 pub batch_security_notes: Option<Vec<String>>,
129 #[serde(default)]
133 pub batch_operational_notes: Option<Vec<String>>,
134 #[serde(default)]
136 pub tool_approval_policy: Option<String>,
137 #[serde(default)]
138 pub max_tools: Option<u32>,
139 #[serde(default)]
140 pub prompt_caching: Option<bool>,
141 #[serde(default)]
145 pub prompt_cache_ttls: Option<Vec<String>>,
146 #[serde(default)]
150 pub cache_breakpoint_style: Option<String>,
151 #[serde(default)]
154 pub vision: Option<bool>,
155 #[serde(default, alias = "audio_supported")]
158 pub audio: Option<bool>,
159 #[serde(default, alias = "pdf_supported")]
162 pub pdf: Option<bool>,
163 #[serde(default, alias = "video_supported")]
166 pub video: Option<bool>,
167 #[serde(default)]
169 pub files_api_supported: Option<bool>,
170 #[serde(default)]
173 pub file_upload_wire_format: Option<String>,
174 #[serde(default)]
177 pub structured_output: Option<String>,
178 #[serde(default)]
181 pub json_schema: Option<String>,
182 #[serde(default)]
185 pub prefers_xml_scaffolding: Option<bool>,
186 #[serde(default)]
191 pub reserved_tool_call_token: Option<bool>,
192 #[serde(default)]
195 pub prefers_markdown_scaffolding: Option<bool>,
196 #[serde(default)]
200 pub structured_output_mode: Option<String>,
201 #[serde(default)]
203 pub supports_assistant_prefill: Option<bool>,
204 #[serde(default)]
207 pub prefers_role_developer: Option<bool>,
208 #[serde(default)]
211 pub prefers_xml_tools: Option<bool>,
212 #[serde(default)]
216 pub thinking_block_style: Option<String>,
217 #[serde(default)]
220 pub thinking_modes: Option<Vec<String>>,
221 #[serde(default)]
224 pub interleaved_thinking_supported: Option<bool>,
225 #[serde(default)]
227 pub anthropic_beta_features: Option<Vec<String>>,
228 #[serde(default)]
231 pub thinking: Option<bool>,
232 #[serde(default)]
234 pub vision_supported: Option<bool>,
235 #[serde(default)]
237 pub image_url_input_supported: Option<bool>,
238 #[serde(default)]
245 pub preserve_thinking: Option<bool>,
246 #[serde(default)]
250 pub server_parser: Option<String>,
251 #[serde(default)]
255 pub honors_chat_template_kwargs: Option<bool>,
256 #[serde(default)]
259 pub chat_template_options_field: Option<String>,
260 #[serde(default)]
263 pub requires_completion_tokens: Option<bool>,
264 #[serde(default)]
271 pub chat_completions_unsupported: Option<bool>,
272 #[serde(default)]
276 pub reasoning_tools_require_responses: Option<bool>,
277 #[serde(default)]
281 pub requires_streaming: Option<bool>,
282 #[serde(default)]
286 pub reasoning_effort_supported: Option<bool>,
287 #[serde(default)]
290 pub reasoning_effort_levels: Option<Vec<String>>,
291 #[serde(default)]
295 pub reasoning_none_supported: Option<bool>,
296 #[serde(default)]
304 pub max_thinking_budget: Option<i64>,
305 #[serde(default)]
308 pub reasoning_disable_supported: Option<bool>,
309 #[serde(default)]
322 pub reasoning_required_for_tools: Option<bool>,
323 #[serde(default)]
327 pub reasoning_text_promotable: Option<bool>,
328 #[serde(default)]
331 pub reasoning_wire_format: Option<String>,
332 #[serde(default)]
333 pub seed_supported: Option<bool>,
334 #[serde(default)]
335 pub top_k_supported: Option<bool>,
336 #[serde(default)]
337 pub temperature_supported: Option<bool>,
338 #[serde(default)]
339 pub top_p_supported: Option<bool>,
340 #[serde(default)]
341 pub frequency_penalty_supported: Option<bool>,
342 #[serde(default)]
343 pub presence_penalty_supported: Option<bool>,
344 #[serde(default)]
348 pub allowed_tool_choice_modes: Option<Vec<String>>,
349 #[serde(default)]
352 pub requires_tool_result_adjacency: Option<bool>,
353 #[serde(default)]
359 pub supports_parallel_tool_calls: Option<bool>,
360 #[serde(default)]
364 pub tools_exclude_response_format: Option<bool>,
365 #[serde(default)]
369 pub recommended_endpoint: Option<String>,
370 #[serde(default)]
373 pub text_tool_wire_format_supported: Option<bool>,
374 #[serde(default)]
379 pub preferred_tool_format: Option<String>,
380 #[serde(default)]
385 pub tool_mode_parity: Option<String>,
386 #[serde(default)]
388 pub tool_mode_parity_notes: Option<String>,
389 #[serde(default)]
396 pub thinking_disable_directive: Option<String>,
397 #[serde(default)]
410 pub auto_reasoning_overrides: Option<BTreeMap<String, String>>,
411 #[serde(default)]
422 pub provider_route_denylist: Option<Vec<String>>,
423 #[serde(default)]
441 pub openrouter_provider_order: Option<Vec<String>>,
442 #[serde(default)]
456 pub serving_precision: Option<String>,
457 #[serde(default)]
466 pub computer_use_style: Option<ComputerUseStyle>,
467 #[serde(default)]
471 pub screenshot_scaling: Option<ScreenshotScaling>,
472 #[serde(default)]
477 pub safety_ack_flow: Option<bool>,
478}
479
480impl ProviderRule {
481 fn fill_missing_from(&mut self, other: &ProviderRule) {
491 let ProviderRule {
492 model_match: _,
495 version_min: _,
496 extends: _,
497 native_tools,
498 message_wire_format,
499 native_tool_wire_format,
500 defer_loading,
501 tool_search,
502 responses_api,
503 hosted_tools,
504 remote_mcp,
505 conversation_state,
506 compaction,
507 background_mode,
508 batch_api,
509 batch_wire_format,
510 batch_input_mode,
511 batch_discount_percent,
512 batch_turnaround_hours,
513 batch_max_requests,
514 batch_max_input_bytes,
515 batch_result_retention_days,
516 batch_result_ordering,
517 batch_partial_failure,
518 batch_cancellation,
519 batch_security_notes,
520 batch_operational_notes,
521 tool_approval_policy,
522 max_tools,
523 prompt_caching,
524 prompt_cache_ttls,
525 cache_breakpoint_style,
526 vision,
527 audio,
528 pdf,
529 video,
530 files_api_supported,
531 file_upload_wire_format,
532 structured_output,
533 json_schema,
534 prefers_xml_scaffolding,
535 reserved_tool_call_token,
536 prefers_markdown_scaffolding,
537 structured_output_mode,
538 supports_assistant_prefill,
539 prefers_role_developer,
540 prefers_xml_tools,
541 thinking_block_style,
542 thinking_modes,
543 interleaved_thinking_supported,
544 anthropic_beta_features,
545 thinking,
546 vision_supported,
547 image_url_input_supported,
548 preserve_thinking,
549 server_parser,
550 honors_chat_template_kwargs,
551 chat_template_options_field,
552 requires_completion_tokens,
553 chat_completions_unsupported,
554 reasoning_tools_require_responses,
555 requires_streaming,
556 reasoning_effort_supported,
557 reasoning_effort_levels,
558 reasoning_none_supported,
559 max_thinking_budget,
560 reasoning_disable_supported,
561 reasoning_required_for_tools,
562 reasoning_text_promotable,
563 reasoning_wire_format,
564 seed_supported,
565 top_k_supported,
566 temperature_supported,
567 top_p_supported,
568 frequency_penalty_supported,
569 presence_penalty_supported,
570 allowed_tool_choice_modes,
571 requires_tool_result_adjacency,
572 supports_parallel_tool_calls,
573 tools_exclude_response_format,
574 recommended_endpoint,
575 text_tool_wire_format_supported,
576 preferred_tool_format,
577 tool_mode_parity,
578 tool_mode_parity_notes,
579 thinking_disable_directive,
580 auto_reasoning_overrides,
581 provider_route_denylist,
582 openrouter_provider_order,
583 serving_precision,
584 computer_use_style,
585 screenshot_scaling,
586 safety_ack_flow,
587 } = other;
588 fill_opt(&mut self.native_tools, native_tools);
589 fill_opt(&mut self.message_wire_format, message_wire_format);
590 fill_opt(&mut self.native_tool_wire_format, native_tool_wire_format);
591 fill_opt(&mut self.defer_loading, defer_loading);
592 fill_opt(&mut self.tool_search, tool_search);
593 fill_opt(&mut self.responses_api, responses_api);
594 fill_opt(&mut self.hosted_tools, hosted_tools);
595 fill_opt(&mut self.remote_mcp, remote_mcp);
596 fill_opt(&mut self.conversation_state, conversation_state);
597 fill_opt(&mut self.compaction, compaction);
598 fill_opt(&mut self.background_mode, background_mode);
599 fill_opt(&mut self.batch_api, batch_api);
600 fill_opt(&mut self.batch_wire_format, batch_wire_format);
601 fill_opt(&mut self.batch_input_mode, batch_input_mode);
602 fill_opt(&mut self.batch_discount_percent, batch_discount_percent);
603 fill_opt(&mut self.batch_turnaround_hours, batch_turnaround_hours);
604 fill_opt(&mut self.batch_max_requests, batch_max_requests);
605 fill_opt(&mut self.batch_max_input_bytes, batch_max_input_bytes);
606 fill_opt(
607 &mut self.batch_result_retention_days,
608 batch_result_retention_days,
609 );
610 fill_opt(&mut self.batch_result_ordering, batch_result_ordering);
611 fill_opt(&mut self.batch_partial_failure, batch_partial_failure);
612 fill_opt(&mut self.batch_cancellation, batch_cancellation);
613 fill_opt(&mut self.batch_security_notes, batch_security_notes);
614 fill_opt(&mut self.batch_operational_notes, batch_operational_notes);
615 fill_opt(&mut self.tool_approval_policy, tool_approval_policy);
616 fill_opt(&mut self.max_tools, max_tools);
617 fill_opt(&mut self.prompt_caching, prompt_caching);
618 fill_opt(&mut self.prompt_cache_ttls, prompt_cache_ttls);
619 fill_opt(&mut self.cache_breakpoint_style, cache_breakpoint_style);
620 fill_opt(&mut self.audio, audio);
621 fill_opt(&mut self.pdf, pdf);
622 fill_opt(&mut self.video, video);
623 fill_opt(&mut self.files_api_supported, files_api_supported);
624 fill_opt(&mut self.file_upload_wire_format, file_upload_wire_format);
625 fill_opt(&mut self.prefers_xml_scaffolding, prefers_xml_scaffolding);
626 fill_opt(&mut self.reserved_tool_call_token, reserved_tool_call_token);
627 fill_opt(
628 &mut self.prefers_markdown_scaffolding,
629 prefers_markdown_scaffolding,
630 );
631 fill_opt(&mut self.structured_output_mode, structured_output_mode);
632 fill_opt(
633 &mut self.supports_assistant_prefill,
634 supports_assistant_prefill,
635 );
636 fill_opt(&mut self.prefers_role_developer, prefers_role_developer);
637 fill_opt(&mut self.prefers_xml_tools, prefers_xml_tools);
638 fill_opt(&mut self.thinking_block_style, thinking_block_style);
639 fill_opt(
640 &mut self.interleaved_thinking_supported,
641 interleaved_thinking_supported,
642 );
643 fill_opt(&mut self.anthropic_beta_features, anthropic_beta_features);
644 fill_opt(
645 &mut self.image_url_input_supported,
646 image_url_input_supported,
647 );
648 fill_opt(&mut self.preserve_thinking, preserve_thinking);
649 fill_opt(&mut self.server_parser, server_parser);
650 fill_opt(
651 &mut self.honors_chat_template_kwargs,
652 honors_chat_template_kwargs,
653 );
654 fill_opt(
655 &mut self.chat_template_options_field,
656 chat_template_options_field,
657 );
658 fill_opt(
659 &mut self.requires_completion_tokens,
660 requires_completion_tokens,
661 );
662 fill_opt(
663 &mut self.chat_completions_unsupported,
664 chat_completions_unsupported,
665 );
666 fill_opt(
667 &mut self.reasoning_tools_require_responses,
668 reasoning_tools_require_responses,
669 );
670 fill_opt(&mut self.requires_streaming, requires_streaming);
671 fill_opt(
672 &mut self.reasoning_effort_supported,
673 reasoning_effort_supported,
674 );
675 fill_opt(&mut self.reasoning_effort_levels, reasoning_effort_levels);
676 fill_opt(&mut self.reasoning_none_supported, reasoning_none_supported);
677 fill_opt(&mut self.max_thinking_budget, max_thinking_budget);
678 fill_opt(
679 &mut self.reasoning_disable_supported,
680 reasoning_disable_supported,
681 );
682 fill_opt(
683 &mut self.reasoning_required_for_tools,
684 reasoning_required_for_tools,
685 );
686 fill_opt(
687 &mut self.reasoning_text_promotable,
688 reasoning_text_promotable,
689 );
690 fill_opt(&mut self.reasoning_wire_format, reasoning_wire_format);
691 fill_opt(&mut self.seed_supported, seed_supported);
692 fill_opt(&mut self.top_k_supported, top_k_supported);
693 fill_opt(&mut self.temperature_supported, temperature_supported);
694 fill_opt(&mut self.top_p_supported, top_p_supported);
695 fill_opt(
696 &mut self.frequency_penalty_supported,
697 frequency_penalty_supported,
698 );
699 fill_opt(
700 &mut self.presence_penalty_supported,
701 presence_penalty_supported,
702 );
703 fill_opt(
704 &mut self.allowed_tool_choice_modes,
705 allowed_tool_choice_modes,
706 );
707 fill_opt(
708 &mut self.requires_tool_result_adjacency,
709 requires_tool_result_adjacency,
710 );
711 fill_opt(
712 &mut self.supports_parallel_tool_calls,
713 supports_parallel_tool_calls,
714 );
715 fill_opt(
716 &mut self.tools_exclude_response_format,
717 tools_exclude_response_format,
718 );
719 fill_opt(&mut self.recommended_endpoint, recommended_endpoint);
720 fill_opt(
721 &mut self.text_tool_wire_format_supported,
722 text_tool_wire_format_supported,
723 );
724 fill_opt(&mut self.preferred_tool_format, preferred_tool_format);
725 fill_opt(&mut self.tool_mode_parity, tool_mode_parity);
726 fill_opt(&mut self.tool_mode_parity_notes, tool_mode_parity_notes);
727 fill_opt(
728 &mut self.thinking_disable_directive,
729 thinking_disable_directive,
730 );
731 fill_opt(&mut self.auto_reasoning_overrides, auto_reasoning_overrides);
732 fill_opt(&mut self.provider_route_denylist, provider_route_denylist);
733 fill_opt(
734 &mut self.openrouter_provider_order,
735 openrouter_provider_order,
736 );
737 fill_opt(&mut self.serving_precision, serving_precision);
738 fill_opt(&mut self.computer_use_style, computer_use_style);
739 fill_opt(&mut self.screenshot_scaling, screenshot_scaling);
740 fill_opt(&mut self.safety_ack_flow, safety_ack_flow);
741 if self.structured_output.is_none() && self.json_schema.is_none() {
747 self.structured_output.clone_from(structured_output);
748 self.json_schema.clone_from(json_schema);
749 }
750 if self.thinking_modes.is_none() && self.thinking.is_none() {
751 self.thinking_modes.clone_from(thinking_modes);
752 self.thinking.clone_from(thinking);
753 }
754 if self.vision.is_none() && self.vision_supported.is_none() {
755 self.vision.clone_from(vision);
756 self.vision_supported.clone_from(vision_supported);
757 }
758 }
759}
760
761pub(super) struct MatchedCapabilityRule {
762 pub(super) provider: String,
764 pub(super) rule: ProviderRule,
767 pub(super) matched_patterns: Vec<String>,
770}
771
772#[derive(Default)]
778struct RuleResolution {
779 provider: Option<String>,
781 merged: Option<ProviderRule>,
782 matched_patterns: Vec<String>,
784}
785
786impl RuleResolution {
787 fn absorb(&mut self, layer_provider: &str, rule: &ProviderRule) -> bool {
791 if self.provider.is_none() {
792 self.provider = Some(layer_provider.to_string());
793 }
794 self.matched_patterns.push(rule.model_match.clone());
795 match &mut self.merged {
796 None => self.merged = Some(rule.clone()),
797 Some(merged) => merged.fill_missing_from(rule),
798 }
799 !rule.extends
800 }
801
802 fn into_matched(self) -> Option<MatchedCapabilityRule> {
803 Some(MatchedCapabilityRule {
804 provider: self.provider?,
805 rule: self.merged.expect("merged is set whenever provider is set"),
806 matched_patterns: self.matched_patterns,
807 })
808 }
809}
810
811fn absorb_layer_matches(
816 user: Option<&CapabilitiesFile>,
817 builtin: &CapabilitiesFile,
818 layer_provider: &str,
819 model: &str,
820 resolution: &mut RuleResolution,
821) -> bool {
822 for file in user.into_iter().chain(std::iter::once(builtin)) {
823 if let Some(rules) = file.provider.get(layer_provider) {
824 for rule in rules {
825 if rule_matches(rule, model) && resolution.absorb(layer_provider, rule) {
826 return true;
827 }
828 }
829 }
830 }
831 false
832}
833
834fn resolve_rule_chain(
842 user: Option<&CapabilitiesFile>,
843 builtin: &CapabilitiesFile,
844 provider: &str,
845 model: &str,
846) -> (RuleResolution, ProviderDefaults) {
847 let mut resolution = RuleResolution::default();
848 let mut effective_defaults = ProviderDefaults::default();
849 let mut current = provider.to_string();
850 let mut visited = HashSet::new();
851 while visited.insert(current.clone()) {
852 let layer_defaults = merged_provider_defaults(user, builtin, ¤t);
853 if effective_defaults.has_any_field() {
854 effective_defaults.fill_missing_from(&layer_defaults);
855 } else {
856 effective_defaults.overlay(&layer_defaults);
857 }
858 if absorb_layer_matches(user, builtin, ¤t, model, &mut resolution) {
859 break;
860 }
861 let next = user
862 .and_then(|file| file.provider_family.get(¤t))
863 .or_else(|| builtin.provider_family.get(¤t))
864 .cloned();
865 match next {
866 Some(parent) => current = parent,
867 None => break,
868 }
869 }
870 (resolution, effective_defaults)
871}
872
873pub(super) fn first_matching_rule(
874 user: Option<&CapabilitiesFile>,
875 builtin: &CapabilitiesFile,
876 provider: &str,
877 model: &str,
878) -> Option<MatchedCapabilityRule> {
879 resolve_rule_chain(user, builtin, provider, model)
880 .0
881 .into_matched()
882}
883
884pub(super) fn rule_thinking_modes(rule: &ProviderRule) -> Vec<String> {
885 rule.thinking_modes.clone().unwrap_or_else(|| {
886 if rule.thinking.unwrap_or(false) {
887 vec!["enabled".to_string()]
888 } else {
889 Vec::new()
890 }
891 })
892}
893
894pub(super) fn rule_vision(rule: &ProviderRule) -> bool {
895 rule.vision.or(rule.vision_supported).unwrap_or(false)
896}
897
898pub(super) fn lookup_with(
899 provider: &str,
900 model: &str,
901 builtin: &CapabilitiesFile,
902 user: Option<&CapabilitiesFile>,
903) -> Capabilities {
904 if provider == "mock" {
915 for family in ["anthropic", "openai", "gemini"] {
916 let defaults = merged_provider_defaults(user, builtin, family);
917 let mut resolution = RuleResolution::default();
918 absorb_layer_matches(user, builtin, family, model, &mut resolution);
919 if let Some(rule) = resolution.merged.as_ref() {
920 let mut caps = rule_to_caps(rule, &defaults);
921 if family == "anthropic" {
922 caps.native_tool_wire_format = "openai".to_string();
923 }
924 return caps;
925 }
926 }
927 return Capabilities::default();
928 }
929
930 let (resolution, effective_defaults) = resolve_rule_chain(user, builtin, provider, model);
933 if let Some(rule) = resolution.merged.as_ref() {
934 return rule_to_caps(rule, &effective_defaults);
935 }
936 if effective_defaults.has_any_field() {
937 return defaults_to_caps(&effective_defaults);
938 }
939 Capabilities::default()
940}
941
942fn merged_provider_defaults(
943 user: Option<&CapabilitiesFile>,
944 builtin: &CapabilitiesFile,
945 provider: &str,
946) -> ProviderDefaults {
947 let mut defaults = builtin
948 .provider_defaults
949 .get(provider)
950 .cloned()
951 .unwrap_or_default();
952 if let Some(user_defaults) = user.and_then(|file| file.provider_defaults.get(provider)) {
953 defaults.overlay(user_defaults);
954 }
955 defaults
956}
957
958fn defaults_to_caps(defaults: &ProviderDefaults) -> Capabilities {
959 let empty = ProviderRule {
960 model_match: "*".to_string(),
961 version_min: None,
962 extends: false,
963 native_tools: None,
964 message_wire_format: None,
965 native_tool_wire_format: None,
966 defer_loading: None,
967 tool_search: None,
968 responses_api: None,
969 hosted_tools: None,
970 remote_mcp: None,
971 conversation_state: None,
972 compaction: None,
973 background_mode: None,
974 tool_approval_policy: None,
975 batch_api: None,
976 batch_wire_format: None,
977 batch_input_mode: None,
978 batch_discount_percent: None,
979 batch_turnaround_hours: None,
980 batch_max_requests: None,
981 batch_max_input_bytes: None,
982 batch_result_retention_days: None,
983 batch_result_ordering: None,
984 batch_partial_failure: None,
985 batch_cancellation: None,
986 batch_security_notes: None,
987 batch_operational_notes: None,
988 max_tools: None,
989 prompt_caching: None,
990 prompt_cache_ttls: None,
991 cache_breakpoint_style: None,
992 vision: None,
993 audio: None,
994 pdf: None,
995 video: None,
996 files_api_supported: None,
997 file_upload_wire_format: None,
998 structured_output: None,
999 prefers_xml_scaffolding: None,
1000 reserved_tool_call_token: None,
1001 prefers_markdown_scaffolding: None,
1002 structured_output_mode: None,
1003 supports_assistant_prefill: None,
1004 prefers_role_developer: None,
1005 prefers_xml_tools: None,
1006 thinking_block_style: None,
1007 json_schema: None,
1008 thinking_modes: None,
1009 interleaved_thinking_supported: None,
1010 anthropic_beta_features: None,
1011 thinking: None,
1012 vision_supported: None,
1013 image_url_input_supported: None,
1014 preserve_thinking: None,
1015 server_parser: None,
1016 honors_chat_template_kwargs: None,
1017 chat_template_options_field: None,
1018 requires_completion_tokens: None,
1019 chat_completions_unsupported: None,
1020 reasoning_tools_require_responses: None,
1021 requires_streaming: None,
1022 reasoning_effort_supported: None,
1023 reasoning_effort_levels: None,
1024 reasoning_none_supported: None,
1025 max_thinking_budget: None,
1026 reasoning_disable_supported: None,
1027 reasoning_required_for_tools: None,
1028 reasoning_text_promotable: None,
1029 reasoning_wire_format: None,
1030 seed_supported: None,
1031 top_k_supported: None,
1032 temperature_supported: None,
1033 top_p_supported: None,
1034 frequency_penalty_supported: None,
1035 presence_penalty_supported: None,
1036 allowed_tool_choice_modes: None,
1037 requires_tool_result_adjacency: None,
1038 supports_parallel_tool_calls: None,
1039 tools_exclude_response_format: None,
1040 recommended_endpoint: None,
1041 text_tool_wire_format_supported: None,
1042 preferred_tool_format: None,
1043 tool_mode_parity: None,
1044 tool_mode_parity_notes: None,
1045 thinking_disable_directive: None,
1046 auto_reasoning_overrides: None,
1047 provider_route_denylist: None,
1048 openrouter_provider_order: None,
1049 serving_precision: None,
1050 computer_use_style: None,
1051 screenshot_scaling: None,
1052 safety_ack_flow: None,
1053 };
1054 let mut caps = rule_to_caps(&empty, defaults);
1055 caps.preferred_tool_format = None;
1056 caps.tool_mode_parity = None;
1057 caps
1058}
1059
1060fn rule_to_caps(rule: &ProviderRule, defaults: &ProviderDefaults) -> Capabilities {
1061 let thinking_modes = rule_thinking_modes(rule);
1062 let thinking_block_style = rule_thinking_block_style(rule);
1063 let prompt_caching = rule.prompt_caching.unwrap_or(false);
1064 let emits_inline_reasoning = thinking_block_style == "inline";
1069 Capabilities {
1070 native_tools: rule.native_tools.unwrap_or(false),
1071 message_wire_format: WireDialect::from_message_wire_format(
1072 &rule
1073 .message_wire_format
1074 .clone()
1075 .or_else(|| defaults.message_wire_format.clone())
1076 .unwrap_or_else(|| "openai".to_string()),
1077 ),
1078 native_tool_wire_format: rule
1079 .native_tool_wire_format
1080 .clone()
1081 .or_else(|| defaults.native_tool_wire_format.clone())
1082 .unwrap_or_else(|| "openai".to_string()),
1083 defer_loading: rule.defer_loading.unwrap_or(false),
1084 tool_search: rule.tool_search.clone().unwrap_or_default(),
1085 responses_api: rule.responses_api.unwrap_or(false),
1086 hosted_tools: rule.hosted_tools.clone().unwrap_or_default(),
1087 remote_mcp: rule.remote_mcp.unwrap_or(false),
1088 conversation_state: rule.conversation_state.unwrap_or(false),
1089 compaction: rule.compaction.unwrap_or(false),
1090 background_mode: rule.background_mode.unwrap_or(false),
1091 batch_api: rule.batch_api.or(defaults.batch_api).unwrap_or(false),
1092 batch_wire_format: rule
1093 .batch_wire_format
1094 .clone()
1095 .or_else(|| defaults.batch_wire_format.clone()),
1096 batch_input_mode: rule
1097 .batch_input_mode
1098 .clone()
1099 .or_else(|| defaults.batch_input_mode.clone()),
1100 batch_discount_percent: rule
1101 .batch_discount_percent
1102 .or(defaults.batch_discount_percent),
1103 batch_turnaround_hours: rule
1104 .batch_turnaround_hours
1105 .or(defaults.batch_turnaround_hours),
1106 batch_max_requests: rule.batch_max_requests.or(defaults.batch_max_requests),
1107 batch_max_input_bytes: rule
1108 .batch_max_input_bytes
1109 .or(defaults.batch_max_input_bytes),
1110 batch_result_retention_days: rule
1111 .batch_result_retention_days
1112 .or(defaults.batch_result_retention_days),
1113 batch_result_ordering: rule
1114 .batch_result_ordering
1115 .clone()
1116 .or_else(|| defaults.batch_result_ordering.clone()),
1117 batch_partial_failure: rule
1118 .batch_partial_failure
1119 .clone()
1120 .or_else(|| defaults.batch_partial_failure.clone()),
1121 batch_cancellation: rule
1122 .batch_cancellation
1123 .clone()
1124 .or_else(|| defaults.batch_cancellation.clone()),
1125 batch_security_notes: rule
1126 .batch_security_notes
1127 .clone()
1128 .or_else(|| defaults.batch_security_notes.clone())
1129 .unwrap_or_default(),
1130 batch_operational_notes: rule
1131 .batch_operational_notes
1132 .clone()
1133 .or_else(|| defaults.batch_operational_notes.clone())
1134 .unwrap_or_default(),
1135 tool_approval_policy: rule.tool_approval_policy.clone(),
1136 max_tools: rule.max_tools,
1137 prompt_caching,
1138 prompt_cache_ttls: if prompt_caching {
1139 rule.prompt_cache_ttls
1140 .clone()
1141 .or_else(|| defaults.prompt_cache_ttls.clone())
1142 .unwrap_or_default()
1143 } else {
1144 Vec::new()
1145 },
1146 cache_breakpoint_style: rule
1147 .cache_breakpoint_style
1148 .clone()
1149 .unwrap_or_else(|| "none".to_string()),
1150 vision: rule_vision(rule),
1151 audio: rule.audio.unwrap_or(false),
1152 pdf: rule.pdf.unwrap_or(false),
1153 video: rule.video.unwrap_or(false),
1154 files_api_supported: rule
1155 .files_api_supported
1156 .or(defaults.files_api_supported)
1157 .unwrap_or(false),
1158 file_upload_wire_format: rule
1159 .file_upload_wire_format
1160 .clone()
1161 .or_else(|| defaults.file_upload_wire_format.clone()),
1162 structured_output: rule_structured_output(rule),
1163 json_schema: rule_structured_output(rule),
1164 prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
1165 reserved_tool_call_token: rule.reserved_tool_call_token.unwrap_or(false),
1166 prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
1167 structured_output_mode: rule_structured_output_mode(rule),
1168 supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
1169 prefers_role_developer: rule.prefers_role_developer.unwrap_or(false),
1170 prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
1171 thinking_block_style,
1172 emits_inline_reasoning,
1173 thinking_modes,
1174 interleaved_thinking_supported: rule.interleaved_thinking_supported.unwrap_or(false),
1175 anthropic_beta_features: rule.anthropic_beta_features.clone().unwrap_or_default(),
1176 vision_supported: rule.vision_supported.unwrap_or(false),
1177 image_url_input_supported: rule
1178 .image_url_input_supported
1179 .or(defaults.image_url_input_supported)
1180 .unwrap_or(true),
1181 preserve_thinking: rule.preserve_thinking.unwrap_or(false),
1182 server_parser: rule
1183 .server_parser
1184 .clone()
1185 .unwrap_or_else(|| "none".to_string()),
1186 honors_chat_template_kwargs: rule.honors_chat_template_kwargs.unwrap_or(false),
1187 chat_template_options_field: rule.chat_template_options_field.clone(),
1188 requires_completion_tokens: rule.requires_completion_tokens.unwrap_or(false),
1189 chat_completions_unsupported: rule.chat_completions_unsupported.unwrap_or(false),
1190 reasoning_tools_require_responses: rule.reasoning_tools_require_responses.unwrap_or(false),
1191 requires_streaming: rule.requires_streaming.unwrap_or(false),
1192 reasoning_effort_supported: rule.reasoning_effort_supported.unwrap_or(false),
1193 reasoning_effort_levels: rule.reasoning_effort_levels.clone().unwrap_or_default(),
1194 reasoning_none_supported: rule.reasoning_none_supported.unwrap_or(false),
1195 max_thinking_budget: rule.max_thinking_budget,
1196 reasoning_disable_supported: rule.reasoning_disable_supported.unwrap_or(true),
1197 reasoning_required_for_tools: rule.reasoning_required_for_tools.unwrap_or(false),
1198 reasoning_text_promotable: rule.reasoning_text_promotable.unwrap_or(false),
1199 reasoning_wire_format: rule
1200 .reasoning_wire_format
1201 .clone()
1202 .or_else(|| defaults.reasoning_wire_format.clone()),
1203 seed_supported: rule
1204 .seed_supported
1205 .or(defaults.seed_supported)
1206 .unwrap_or(true),
1207 top_k_supported: rule
1208 .top_k_supported
1209 .or(defaults.top_k_supported)
1210 .unwrap_or(true),
1211 temperature_supported: rule
1212 .temperature_supported
1213 .or(defaults.temperature_supported)
1214 .unwrap_or(true),
1215 top_p_supported: rule
1216 .top_p_supported
1217 .or(defaults.top_p_supported)
1218 .unwrap_or(true),
1219 frequency_penalty_supported: rule
1220 .frequency_penalty_supported
1221 .or(defaults.frequency_penalty_supported)
1222 .unwrap_or(true),
1223 presence_penalty_supported: rule
1224 .presence_penalty_supported
1225 .or(defaults.presence_penalty_supported)
1226 .unwrap_or(true),
1227 allowed_tool_choice_modes: rule.allowed_tool_choice_modes.clone().unwrap_or_default(),
1228 requires_tool_result_adjacency: rule.requires_tool_result_adjacency.unwrap_or(false),
1229 supports_parallel_tool_calls: rule.supports_parallel_tool_calls.unwrap_or(true),
1230 tools_exclude_response_format: rule.tools_exclude_response_format.unwrap_or(false),
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 provider_route_denylist: rule.provider_route_denylist.clone().unwrap_or_default(),
1239 openrouter_provider_order: rule.openrouter_provider_order.clone().unwrap_or_default(),
1240 serving_precision: rule
1241 .serving_precision
1242 .clone()
1243 .unwrap_or_else(|| "unverified".to_string()),
1244 computer_use_style: rule.computer_use_style,
1245 screenshot_scaling: rule.screenshot_scaling,
1246 safety_ack_flow: rule.safety_ack_flow.unwrap_or(false),
1247 }
1248}
1249
1250pub(super) fn rule_preferred_tool_format(rule: &ProviderRule) -> String {
1251 rule.preferred_tool_format.clone().unwrap_or_else(|| {
1257 if rule.native_tools.unwrap_or(false) {
1258 "native".to_string()
1259 } else {
1260 "json".to_string()
1261 }
1262 })
1263}
1264
1265pub(super) fn rule_tool_mode_parity(rule: &ProviderRule) -> String {
1266 rule.tool_mode_parity.clone().unwrap_or_else(|| {
1267 match (
1268 rule.native_tools.unwrap_or(false),
1269 rule.text_tool_wire_format_supported.unwrap_or(true),
1270 ) {
1271 (true, true) => "unknown".to_string(),
1272 (true, false) => "native_only".to_string(),
1273 (false, true) => "text_only".to_string(),
1274 (false, false) => "unsupported".to_string(),
1275 }
1276 })
1277}
1278
1279pub(super) fn rule_structured_output(rule: &ProviderRule) -> Option<String> {
1280 rule.structured_output
1281 .clone()
1282 .or_else(|| rule.json_schema.clone())
1283 .filter(|value| value != "none")
1284}
1285
1286pub(super) fn rule_structured_output_mode(rule: &ProviderRule) -> String {
1287 if let Some(mode) = &rule.structured_output_mode {
1288 return mode.clone();
1289 }
1290 match rule_structured_output(rule).as_deref() {
1291 Some("native") | Some("format_kw") => "native_json".to_string(),
1292 Some("tool_use") => "xml_tagged".to_string(),
1293 _ => "none".to_string(),
1294 }
1295}
1296
1297pub(super) fn rule_thinking_block_style(rule: &ProviderRule) -> String {
1298 rule.thinking_block_style.clone().unwrap_or_else(|| {
1299 if rule.reasoning_effort_supported.unwrap_or(false)
1300 || rule.requires_completion_tokens.unwrap_or(false)
1301 {
1302 "reasoning_summary".to_string()
1303 } else {
1304 "none".to_string()
1305 }
1306 })
1307}
1308
1309pub(crate) fn rule_matches(rule: &ProviderRule, model: &str) -> bool {
1310 let lower = model.to_lowercase();
1311 if !glob_match(&rule.model_match.to_lowercase(), &lower) {
1312 return false;
1313 }
1314 if let Some(version_min) = &rule.version_min {
1315 if version_min.len() != 2 {
1316 return false;
1317 }
1318 let want = (version_min[0], version_min[1]);
1319 let have = match extract_version(model) {
1320 Some(v) => v,
1321 None => return false,
1325 };
1326 if have < want {
1327 return false;
1328 }
1329 }
1330 true
1331}
1332
1333fn extract_version(model: &str) -> Option<(u32, u32)> {
1338 claude_generation(model).or_else(|| gpt_generation(model))
1339}
1340
1341#[cfg(test)]
1342mod tests {
1343 use super::super::lookup::parse_capabilities_toml;
1344 use super::*;
1345
1346 #[test]
1347 fn glob_match_substring() {
1348 assert!(glob_match("*gpt*", "openai/gpt-5.4"));
1349 assert!(glob_match("*claude*", "anthropic/claude-opus-4-7"));
1350 assert!(!glob_match("*xyz*", "openai/gpt-5.4"));
1351 }
1352
1353 fn extends_caps(src: &str) -> Capabilities {
1357 let file = parse_capabilities_toml(src).expect("test capabilities toml parses");
1358 lookup_with("testprov", "test-model", &file, None)
1359 }
1360
1361 #[test]
1362 fn extends_rule_fills_unset_fields_from_later_matching_rule() {
1363 let caps = extends_caps(
1366 r#"
1367[[provider.testprov]]
1368model_match = "test-*"
1369extends = true
1370native_tools = true
1371
1372[[provider.testprov]]
1373model_match = "test-*"
1374vision = true
1375message_wire_format = "anthropic"
1376"#,
1377 );
1378 assert!(caps.native_tools, "field from the extends rule applies");
1379 assert!(
1380 caps.vision,
1381 "unset field filled from the later matching rule"
1382 );
1383 assert_eq!(caps.message_wire_format, WireDialect::Anthropic);
1384 }
1385
1386 #[test]
1387 fn non_extends_rule_terminates_resolution_unchanged() {
1388 let caps = extends_caps(
1392 r#"
1393[[provider.testprov]]
1394model_match = "test-*"
1395native_tools = true
1396
1397[[provider.testprov]]
1398model_match = "test-*"
1399vision = true
1400"#,
1401 );
1402 assert!(caps.native_tools);
1403 assert!(
1404 !caps.vision,
1405 "a non-extends first match must not absorb later rules"
1406 );
1407 }
1408
1409 #[test]
1410 fn extends_rule_does_not_override_explicitly_set_field() {
1411 let caps = extends_caps(
1415 r#"
1416[[provider.testprov]]
1417model_match = "test-*"
1418extends = true
1419native_tools = true
1420
1421[[provider.testprov]]
1422model_match = "test-*"
1423native_tools = false
1424vision = true
1425"#,
1426 );
1427 assert!(
1428 caps.native_tools,
1429 "the extends rule's explicit value is not overridden by a lower rule"
1430 );
1431 assert!(caps.vision, "still fills the field the chain left unset");
1432 }
1433
1434 #[test]
1435 fn extends_chain_falls_through_to_provider_defaults() {
1436 let caps = extends_caps(
1439 r#"
1440[provider_defaults.testprov]
1441seed_supported = true
1442
1443[[provider.testprov]]
1444model_match = "test-*"
1445extends = true
1446native_tools = true
1447"#,
1448 );
1449 assert!(caps.native_tools, "field from the extends rule applies");
1450 assert!(
1451 caps.seed_supported,
1452 "unset field filled from provider defaults"
1453 );
1454 }
1455}