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)]
347 pub stop_supported: Option<bool>,
348 #[serde(default)]
352 pub allowed_tool_choice_modes: Option<Vec<String>>,
353 #[serde(default)]
356 pub requires_tool_result_adjacency: Option<bool>,
357 #[serde(default)]
363 pub supports_parallel_tool_calls: Option<bool>,
364 #[serde(default)]
368 pub tools_exclude_response_format: Option<bool>,
369 #[serde(default)]
373 pub recommended_endpoint: Option<String>,
374 #[serde(default)]
377 pub text_tool_wire_format_supported: Option<bool>,
378 #[serde(default)]
383 pub preferred_tool_format: Option<String>,
384 #[serde(default)]
389 pub tool_mode_parity: Option<String>,
390 #[serde(default)]
392 pub tool_mode_parity_notes: Option<String>,
393 #[serde(default)]
400 pub thinking_disable_directive: Option<String>,
401 #[serde(default)]
414 pub auto_reasoning_overrides: Option<BTreeMap<String, String>>,
415 #[serde(default)]
426 pub provider_route_denylist: Option<Vec<String>>,
427 #[serde(default)]
445 pub openrouter_provider_order: Option<Vec<String>>,
446 #[serde(default)]
460 pub serving_precision: Option<String>,
461 #[serde(default)]
470 pub computer_use_style: Option<ComputerUseStyle>,
471 #[serde(default)]
475 pub screenshot_scaling: Option<ScreenshotScaling>,
476 #[serde(default)]
481 pub safety_ack_flow: Option<bool>,
482}
483
484impl ProviderRule {
485 fn fill_missing_from(&mut self, other: &ProviderRule) {
495 let ProviderRule {
496 model_match: _,
499 version_min: _,
500 extends: _,
501 native_tools,
502 message_wire_format,
503 native_tool_wire_format,
504 defer_loading,
505 tool_search,
506 responses_api,
507 hosted_tools,
508 remote_mcp,
509 conversation_state,
510 compaction,
511 background_mode,
512 batch_api,
513 batch_wire_format,
514 batch_input_mode,
515 batch_discount_percent,
516 batch_turnaround_hours,
517 batch_max_requests,
518 batch_max_input_bytes,
519 batch_result_retention_days,
520 batch_result_ordering,
521 batch_partial_failure,
522 batch_cancellation,
523 batch_security_notes,
524 batch_operational_notes,
525 tool_approval_policy,
526 max_tools,
527 prompt_caching,
528 prompt_cache_ttls,
529 cache_breakpoint_style,
530 vision,
531 audio,
532 pdf,
533 video,
534 files_api_supported,
535 file_upload_wire_format,
536 structured_output,
537 json_schema,
538 prefers_xml_scaffolding,
539 reserved_tool_call_token,
540 prefers_markdown_scaffolding,
541 structured_output_mode,
542 supports_assistant_prefill,
543 prefers_role_developer,
544 prefers_xml_tools,
545 thinking_block_style,
546 thinking_modes,
547 interleaved_thinking_supported,
548 anthropic_beta_features,
549 thinking,
550 vision_supported,
551 image_url_input_supported,
552 preserve_thinking,
553 server_parser,
554 honors_chat_template_kwargs,
555 chat_template_options_field,
556 requires_completion_tokens,
557 chat_completions_unsupported,
558 reasoning_tools_require_responses,
559 requires_streaming,
560 reasoning_effort_supported,
561 reasoning_effort_levels,
562 reasoning_none_supported,
563 max_thinking_budget,
564 reasoning_disable_supported,
565 reasoning_required_for_tools,
566 reasoning_text_promotable,
567 reasoning_wire_format,
568 seed_supported,
569 top_k_supported,
570 temperature_supported,
571 top_p_supported,
572 frequency_penalty_supported,
573 presence_penalty_supported,
574 stop_supported,
575 allowed_tool_choice_modes,
576 requires_tool_result_adjacency,
577 supports_parallel_tool_calls,
578 tools_exclude_response_format,
579 recommended_endpoint,
580 text_tool_wire_format_supported,
581 preferred_tool_format,
582 tool_mode_parity,
583 tool_mode_parity_notes,
584 thinking_disable_directive,
585 auto_reasoning_overrides,
586 provider_route_denylist,
587 openrouter_provider_order,
588 serving_precision,
589 computer_use_style,
590 screenshot_scaling,
591 safety_ack_flow,
592 } = other;
593 fill_opt(&mut self.native_tools, native_tools);
594 fill_opt(&mut self.message_wire_format, message_wire_format);
595 fill_opt(&mut self.native_tool_wire_format, native_tool_wire_format);
596 fill_opt(&mut self.defer_loading, defer_loading);
597 fill_opt(&mut self.tool_search, tool_search);
598 fill_opt(&mut self.responses_api, responses_api);
599 fill_opt(&mut self.hosted_tools, hosted_tools);
600 fill_opt(&mut self.remote_mcp, remote_mcp);
601 fill_opt(&mut self.conversation_state, conversation_state);
602 fill_opt(&mut self.compaction, compaction);
603 fill_opt(&mut self.background_mode, background_mode);
604 fill_opt(&mut self.batch_api, batch_api);
605 fill_opt(&mut self.batch_wire_format, batch_wire_format);
606 fill_opt(&mut self.batch_input_mode, batch_input_mode);
607 fill_opt(&mut self.batch_discount_percent, batch_discount_percent);
608 fill_opt(&mut self.batch_turnaround_hours, batch_turnaround_hours);
609 fill_opt(&mut self.batch_max_requests, batch_max_requests);
610 fill_opt(&mut self.batch_max_input_bytes, batch_max_input_bytes);
611 fill_opt(
612 &mut self.batch_result_retention_days,
613 batch_result_retention_days,
614 );
615 fill_opt(&mut self.batch_result_ordering, batch_result_ordering);
616 fill_opt(&mut self.batch_partial_failure, batch_partial_failure);
617 fill_opt(&mut self.batch_cancellation, batch_cancellation);
618 fill_opt(&mut self.batch_security_notes, batch_security_notes);
619 fill_opt(&mut self.batch_operational_notes, batch_operational_notes);
620 fill_opt(&mut self.tool_approval_policy, tool_approval_policy);
621 fill_opt(&mut self.max_tools, max_tools);
622 fill_opt(&mut self.prompt_caching, prompt_caching);
623 fill_opt(&mut self.prompt_cache_ttls, prompt_cache_ttls);
624 fill_opt(&mut self.cache_breakpoint_style, cache_breakpoint_style);
625 fill_opt(&mut self.audio, audio);
626 fill_opt(&mut self.pdf, pdf);
627 fill_opt(&mut self.video, video);
628 fill_opt(&mut self.files_api_supported, files_api_supported);
629 fill_opt(&mut self.file_upload_wire_format, file_upload_wire_format);
630 fill_opt(&mut self.prefers_xml_scaffolding, prefers_xml_scaffolding);
631 fill_opt(&mut self.reserved_tool_call_token, reserved_tool_call_token);
632 fill_opt(
633 &mut self.prefers_markdown_scaffolding,
634 prefers_markdown_scaffolding,
635 );
636 fill_opt(&mut self.structured_output_mode, structured_output_mode);
637 fill_opt(
638 &mut self.supports_assistant_prefill,
639 supports_assistant_prefill,
640 );
641 fill_opt(&mut self.prefers_role_developer, prefers_role_developer);
642 fill_opt(&mut self.prefers_xml_tools, prefers_xml_tools);
643 fill_opt(&mut self.thinking_block_style, thinking_block_style);
644 fill_opt(
645 &mut self.interleaved_thinking_supported,
646 interleaved_thinking_supported,
647 );
648 fill_opt(&mut self.anthropic_beta_features, anthropic_beta_features);
649 fill_opt(
650 &mut self.image_url_input_supported,
651 image_url_input_supported,
652 );
653 fill_opt(&mut self.preserve_thinking, preserve_thinking);
654 fill_opt(&mut self.server_parser, server_parser);
655 fill_opt(
656 &mut self.honors_chat_template_kwargs,
657 honors_chat_template_kwargs,
658 );
659 fill_opt(
660 &mut self.chat_template_options_field,
661 chat_template_options_field,
662 );
663 fill_opt(
664 &mut self.requires_completion_tokens,
665 requires_completion_tokens,
666 );
667 fill_opt(
668 &mut self.chat_completions_unsupported,
669 chat_completions_unsupported,
670 );
671 fill_opt(
672 &mut self.reasoning_tools_require_responses,
673 reasoning_tools_require_responses,
674 );
675 fill_opt(&mut self.requires_streaming, requires_streaming);
676 fill_opt(
677 &mut self.reasoning_effort_supported,
678 reasoning_effort_supported,
679 );
680 fill_opt(&mut self.reasoning_effort_levels, reasoning_effort_levels);
681 fill_opt(&mut self.reasoning_none_supported, reasoning_none_supported);
682 fill_opt(&mut self.max_thinking_budget, max_thinking_budget);
683 fill_opt(
684 &mut self.reasoning_disable_supported,
685 reasoning_disable_supported,
686 );
687 fill_opt(
688 &mut self.reasoning_required_for_tools,
689 reasoning_required_for_tools,
690 );
691 fill_opt(
692 &mut self.reasoning_text_promotable,
693 reasoning_text_promotable,
694 );
695 fill_opt(&mut self.reasoning_wire_format, reasoning_wire_format);
696 fill_opt(&mut self.seed_supported, seed_supported);
697 fill_opt(&mut self.top_k_supported, top_k_supported);
698 fill_opt(&mut self.temperature_supported, temperature_supported);
699 fill_opt(&mut self.top_p_supported, top_p_supported);
700 fill_opt(
701 &mut self.frequency_penalty_supported,
702 frequency_penalty_supported,
703 );
704 fill_opt(
705 &mut self.presence_penalty_supported,
706 presence_penalty_supported,
707 );
708 fill_opt(&mut self.stop_supported, stop_supported);
709 fill_opt(
710 &mut self.allowed_tool_choice_modes,
711 allowed_tool_choice_modes,
712 );
713 fill_opt(
714 &mut self.requires_tool_result_adjacency,
715 requires_tool_result_adjacency,
716 );
717 fill_opt(
718 &mut self.supports_parallel_tool_calls,
719 supports_parallel_tool_calls,
720 );
721 fill_opt(
722 &mut self.tools_exclude_response_format,
723 tools_exclude_response_format,
724 );
725 fill_opt(&mut self.recommended_endpoint, recommended_endpoint);
726 fill_opt(
727 &mut self.text_tool_wire_format_supported,
728 text_tool_wire_format_supported,
729 );
730 fill_opt(&mut self.preferred_tool_format, preferred_tool_format);
731 fill_opt(&mut self.tool_mode_parity, tool_mode_parity);
732 fill_opt(&mut self.tool_mode_parity_notes, tool_mode_parity_notes);
733 fill_opt(
734 &mut self.thinking_disable_directive,
735 thinking_disable_directive,
736 );
737 fill_opt(&mut self.auto_reasoning_overrides, auto_reasoning_overrides);
738 fill_opt(&mut self.provider_route_denylist, provider_route_denylist);
739 fill_opt(
740 &mut self.openrouter_provider_order,
741 openrouter_provider_order,
742 );
743 fill_opt(&mut self.serving_precision, serving_precision);
744 fill_opt(&mut self.computer_use_style, computer_use_style);
745 fill_opt(&mut self.screenshot_scaling, screenshot_scaling);
746 fill_opt(&mut self.safety_ack_flow, safety_ack_flow);
747 if self.structured_output.is_none() && self.json_schema.is_none() {
753 self.structured_output.clone_from(structured_output);
754 self.json_schema.clone_from(json_schema);
755 }
756 if self.thinking_modes.is_none() && self.thinking.is_none() {
757 self.thinking_modes.clone_from(thinking_modes);
758 self.thinking.clone_from(thinking);
759 }
760 if self.vision.is_none() && self.vision_supported.is_none() {
761 self.vision.clone_from(vision);
762 self.vision_supported.clone_from(vision_supported);
763 }
764 }
765}
766
767pub(super) struct MatchedCapabilityRule {
768 pub(super) provider: String,
770 pub(super) rule: ProviderRule,
773 pub(super) matched_patterns: Vec<String>,
776}
777
778#[derive(Default)]
784struct RuleResolution {
785 provider: Option<String>,
787 merged: Option<ProviderRule>,
788 matched_patterns: Vec<String>,
790}
791
792impl RuleResolution {
793 fn absorb(&mut self, layer_provider: &str, rule: &ProviderRule) -> bool {
797 if self.provider.is_none() {
798 self.provider = Some(layer_provider.to_string());
799 }
800 self.matched_patterns.push(rule.model_match.clone());
801 match &mut self.merged {
802 None => self.merged = Some(rule.clone()),
803 Some(merged) => merged.fill_missing_from(rule),
804 }
805 !rule.extends
806 }
807
808 fn into_matched(self) -> Option<MatchedCapabilityRule> {
809 Some(MatchedCapabilityRule {
810 provider: self.provider?,
811 rule: self.merged.expect("merged is set whenever provider is set"),
812 matched_patterns: self.matched_patterns,
813 })
814 }
815}
816
817fn absorb_layer_matches(
822 user: Option<&CapabilitiesFile>,
823 builtin: &CapabilitiesFile,
824 layer_provider: &str,
825 model: &str,
826 resolution: &mut RuleResolution,
827) -> bool {
828 for file in user.into_iter().chain(std::iter::once(builtin)) {
829 if let Some(rules) = file.provider.get(layer_provider) {
830 for rule in rules {
831 if rule_matches(rule, model) && resolution.absorb(layer_provider, rule) {
832 return true;
833 }
834 }
835 }
836 }
837 false
838}
839
840fn resolve_rule_chain(
848 user: Option<&CapabilitiesFile>,
849 builtin: &CapabilitiesFile,
850 provider: &str,
851 model: &str,
852) -> (RuleResolution, ProviderDefaults) {
853 let mut resolution = RuleResolution::default();
854 let mut effective_defaults = ProviderDefaults::default();
855 let mut current = provider.to_string();
856 let mut visited = HashSet::new();
857 while visited.insert(current.clone()) {
858 let layer_defaults = merged_provider_defaults(user, builtin, ¤t);
859 if effective_defaults.has_any_field() {
860 effective_defaults.fill_missing_from(&layer_defaults);
861 } else {
862 effective_defaults.overlay(&layer_defaults);
863 }
864 if absorb_layer_matches(user, builtin, ¤t, model, &mut resolution) {
865 break;
866 }
867 let next = user
868 .and_then(|file| file.provider_family.get(¤t))
869 .or_else(|| builtin.provider_family.get(¤t))
870 .cloned();
871 match next {
872 Some(parent) => current = parent,
873 None => break,
874 }
875 }
876 (resolution, effective_defaults)
877}
878
879pub(super) fn first_matching_rule(
880 user: Option<&CapabilitiesFile>,
881 builtin: &CapabilitiesFile,
882 provider: &str,
883 model: &str,
884) -> Option<MatchedCapabilityRule> {
885 resolve_rule_chain(user, builtin, provider, model)
886 .0
887 .into_matched()
888}
889
890pub(super) fn rule_thinking_modes(rule: &ProviderRule) -> Vec<String> {
891 rule.thinking_modes.clone().unwrap_or_else(|| {
892 if rule.thinking.unwrap_or(false) {
893 vec!["enabled".to_string()]
894 } else {
895 Vec::new()
896 }
897 })
898}
899
900pub(super) fn rule_vision(rule: &ProviderRule) -> bool {
901 rule.vision.or(rule.vision_supported).unwrap_or(false)
902}
903
904pub(super) fn lookup_with(
905 provider: &str,
906 model: &str,
907 builtin: &CapabilitiesFile,
908 user: Option<&CapabilitiesFile>,
909) -> Capabilities {
910 if provider == "mock" {
921 for family in ["anthropic", "openai", "gemini"] {
922 let defaults = merged_provider_defaults(user, builtin, family);
923 let mut resolution = RuleResolution::default();
924 absorb_layer_matches(user, builtin, family, model, &mut resolution);
925 if let Some(rule) = resolution.merged.as_ref() {
926 let mut caps = rule_to_caps(rule, &defaults);
927 if family == "anthropic" {
928 caps.native_tool_wire_format = "openai".to_string();
929 }
930 return caps;
931 }
932 }
933 return Capabilities::default();
934 }
935
936 let (resolution, effective_defaults) = resolve_rule_chain(user, builtin, provider, model);
939 if let Some(rule) = resolution.merged.as_ref() {
940 return rule_to_caps(rule, &effective_defaults);
941 }
942 if effective_defaults.has_any_field() {
943 return defaults_to_caps(&effective_defaults);
944 }
945 Capabilities::default()
946}
947
948fn merged_provider_defaults(
949 user: Option<&CapabilitiesFile>,
950 builtin: &CapabilitiesFile,
951 provider: &str,
952) -> ProviderDefaults {
953 let mut defaults = builtin
954 .provider_defaults
955 .get(provider)
956 .cloned()
957 .unwrap_or_default();
958 if let Some(user_defaults) = user.and_then(|file| file.provider_defaults.get(provider)) {
959 defaults.overlay(user_defaults);
960 }
961 defaults
962}
963
964fn defaults_to_caps(defaults: &ProviderDefaults) -> Capabilities {
965 let empty = ProviderRule {
966 model_match: "*".to_string(),
967 version_min: None,
968 extends: false,
969 native_tools: None,
970 message_wire_format: None,
971 native_tool_wire_format: None,
972 defer_loading: None,
973 tool_search: None,
974 responses_api: None,
975 hosted_tools: None,
976 remote_mcp: None,
977 conversation_state: None,
978 compaction: None,
979 background_mode: None,
980 tool_approval_policy: None,
981 batch_api: None,
982 batch_wire_format: None,
983 batch_input_mode: None,
984 batch_discount_percent: None,
985 batch_turnaround_hours: None,
986 batch_max_requests: None,
987 batch_max_input_bytes: None,
988 batch_result_retention_days: None,
989 batch_result_ordering: None,
990 batch_partial_failure: None,
991 batch_cancellation: None,
992 batch_security_notes: None,
993 batch_operational_notes: None,
994 max_tools: None,
995 prompt_caching: None,
996 prompt_cache_ttls: None,
997 cache_breakpoint_style: None,
998 vision: None,
999 audio: None,
1000 pdf: None,
1001 video: None,
1002 files_api_supported: None,
1003 file_upload_wire_format: None,
1004 structured_output: None,
1005 prefers_xml_scaffolding: None,
1006 reserved_tool_call_token: None,
1007 prefers_markdown_scaffolding: None,
1008 structured_output_mode: None,
1009 supports_assistant_prefill: None,
1010 prefers_role_developer: None,
1011 prefers_xml_tools: None,
1012 thinking_block_style: None,
1013 json_schema: None,
1014 thinking_modes: None,
1015 interleaved_thinking_supported: None,
1016 anthropic_beta_features: None,
1017 thinking: None,
1018 vision_supported: None,
1019 image_url_input_supported: None,
1020 preserve_thinking: None,
1021 server_parser: None,
1022 honors_chat_template_kwargs: None,
1023 chat_template_options_field: None,
1024 requires_completion_tokens: None,
1025 chat_completions_unsupported: None,
1026 reasoning_tools_require_responses: None,
1027 requires_streaming: None,
1028 reasoning_effort_supported: None,
1029 reasoning_effort_levels: None,
1030 reasoning_none_supported: None,
1031 max_thinking_budget: None,
1032 reasoning_disable_supported: None,
1033 reasoning_required_for_tools: None,
1034 reasoning_text_promotable: None,
1035 reasoning_wire_format: None,
1036 seed_supported: None,
1037 top_k_supported: None,
1038 temperature_supported: None,
1039 top_p_supported: None,
1040 frequency_penalty_supported: None,
1041 presence_penalty_supported: None,
1042 stop_supported: None,
1043 allowed_tool_choice_modes: None,
1044 requires_tool_result_adjacency: None,
1045 supports_parallel_tool_calls: None,
1046 tools_exclude_response_format: None,
1047 recommended_endpoint: None,
1048 text_tool_wire_format_supported: None,
1049 preferred_tool_format: None,
1050 tool_mode_parity: None,
1051 tool_mode_parity_notes: None,
1052 thinking_disable_directive: None,
1053 auto_reasoning_overrides: None,
1054 provider_route_denylist: None,
1055 openrouter_provider_order: None,
1056 serving_precision: None,
1057 computer_use_style: None,
1058 screenshot_scaling: None,
1059 safety_ack_flow: None,
1060 };
1061 let mut caps = rule_to_caps(&empty, defaults);
1062 caps.preferred_tool_format = None;
1063 caps.tool_mode_parity = None;
1064 caps
1065}
1066
1067fn rule_to_caps(rule: &ProviderRule, defaults: &ProviderDefaults) -> Capabilities {
1068 let thinking_modes = rule_thinking_modes(rule);
1069 let thinking_block_style = rule_thinking_block_style(rule);
1070 let prompt_caching = rule.prompt_caching.unwrap_or(false);
1071 let emits_inline_reasoning = thinking_block_style == "inline";
1076 Capabilities {
1077 native_tools: rule.native_tools.unwrap_or(false),
1078 message_wire_format: WireDialect::from_message_wire_format(
1079 &rule
1080 .message_wire_format
1081 .clone()
1082 .or_else(|| defaults.message_wire_format.clone())
1083 .unwrap_or_else(|| "openai".to_string()),
1084 ),
1085 native_tool_wire_format: rule
1086 .native_tool_wire_format
1087 .clone()
1088 .or_else(|| defaults.native_tool_wire_format.clone())
1089 .unwrap_or_else(|| "openai".to_string()),
1090 defer_loading: rule.defer_loading.unwrap_or(false),
1091 tool_search: rule.tool_search.clone().unwrap_or_default(),
1092 responses_api: rule.responses_api.unwrap_or(false),
1093 hosted_tools: rule.hosted_tools.clone().unwrap_or_default(),
1094 remote_mcp: rule.remote_mcp.unwrap_or(false),
1095 conversation_state: rule.conversation_state.unwrap_or(false),
1096 compaction: rule.compaction.unwrap_or(false),
1097 background_mode: rule.background_mode.unwrap_or(false),
1098 batch_api: rule.batch_api.or(defaults.batch_api).unwrap_or(false),
1099 batch_wire_format: rule
1100 .batch_wire_format
1101 .clone()
1102 .or_else(|| defaults.batch_wire_format.clone()),
1103 batch_input_mode: rule
1104 .batch_input_mode
1105 .clone()
1106 .or_else(|| defaults.batch_input_mode.clone()),
1107 batch_discount_percent: rule
1108 .batch_discount_percent
1109 .or(defaults.batch_discount_percent),
1110 batch_turnaround_hours: rule
1111 .batch_turnaround_hours
1112 .or(defaults.batch_turnaround_hours),
1113 batch_max_requests: rule.batch_max_requests.or(defaults.batch_max_requests),
1114 batch_max_input_bytes: rule
1115 .batch_max_input_bytes
1116 .or(defaults.batch_max_input_bytes),
1117 batch_result_retention_days: rule
1118 .batch_result_retention_days
1119 .or(defaults.batch_result_retention_days),
1120 batch_result_ordering: rule
1121 .batch_result_ordering
1122 .clone()
1123 .or_else(|| defaults.batch_result_ordering.clone()),
1124 batch_partial_failure: rule
1125 .batch_partial_failure
1126 .clone()
1127 .or_else(|| defaults.batch_partial_failure.clone()),
1128 batch_cancellation: rule
1129 .batch_cancellation
1130 .clone()
1131 .or_else(|| defaults.batch_cancellation.clone()),
1132 batch_security_notes: rule
1133 .batch_security_notes
1134 .clone()
1135 .or_else(|| defaults.batch_security_notes.clone())
1136 .unwrap_or_default(),
1137 batch_operational_notes: rule
1138 .batch_operational_notes
1139 .clone()
1140 .or_else(|| defaults.batch_operational_notes.clone())
1141 .unwrap_or_default(),
1142 tool_approval_policy: rule.tool_approval_policy.clone(),
1143 max_tools: rule.max_tools,
1144 prompt_caching,
1145 prompt_cache_ttls: if prompt_caching {
1146 rule.prompt_cache_ttls
1147 .clone()
1148 .or_else(|| defaults.prompt_cache_ttls.clone())
1149 .unwrap_or_default()
1150 } else {
1151 Vec::new()
1152 },
1153 cache_breakpoint_style: rule
1154 .cache_breakpoint_style
1155 .clone()
1156 .unwrap_or_else(|| "none".to_string()),
1157 vision: rule_vision(rule),
1158 audio: rule.audio.unwrap_or(false),
1159 pdf: rule.pdf.unwrap_or(false),
1160 video: rule.video.unwrap_or(false),
1161 files_api_supported: rule
1162 .files_api_supported
1163 .or(defaults.files_api_supported)
1164 .unwrap_or(false),
1165 file_upload_wire_format: rule
1166 .file_upload_wire_format
1167 .clone()
1168 .or_else(|| defaults.file_upload_wire_format.clone()),
1169 structured_output: rule_structured_output(rule),
1170 json_schema: rule_structured_output(rule),
1171 prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
1172 reserved_tool_call_token: rule.reserved_tool_call_token.unwrap_or(false),
1173 prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
1174 structured_output_mode: rule_structured_output_mode(rule),
1175 supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
1176 prefers_role_developer: rule.prefers_role_developer.unwrap_or(false),
1177 prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
1178 thinking_block_style,
1179 emits_inline_reasoning,
1180 thinking_modes,
1181 interleaved_thinking_supported: rule.interleaved_thinking_supported.unwrap_or(false),
1182 anthropic_beta_features: rule.anthropic_beta_features.clone().unwrap_or_default(),
1183 vision_supported: rule.vision_supported.unwrap_or(false),
1184 image_url_input_supported: rule
1185 .image_url_input_supported
1186 .or(defaults.image_url_input_supported)
1187 .unwrap_or(true),
1188 preserve_thinking: rule.preserve_thinking.unwrap_or(false),
1189 server_parser: rule
1190 .server_parser
1191 .clone()
1192 .unwrap_or_else(|| "none".to_string()),
1193 honors_chat_template_kwargs: rule.honors_chat_template_kwargs.unwrap_or(false),
1194 chat_template_options_field: rule.chat_template_options_field.clone(),
1195 requires_completion_tokens: rule.requires_completion_tokens.unwrap_or(false),
1196 chat_completions_unsupported: rule.chat_completions_unsupported.unwrap_or(false),
1197 reasoning_tools_require_responses: rule.reasoning_tools_require_responses.unwrap_or(false),
1198 requires_streaming: rule.requires_streaming.unwrap_or(false),
1199 reasoning_effort_supported: rule.reasoning_effort_supported.unwrap_or(false),
1200 reasoning_effort_levels: rule.reasoning_effort_levels.clone().unwrap_or_default(),
1201 reasoning_none_supported: rule.reasoning_none_supported.unwrap_or(false),
1202 max_thinking_budget: rule.max_thinking_budget,
1203 reasoning_disable_supported: rule.reasoning_disable_supported.unwrap_or(true),
1204 reasoning_required_for_tools: rule.reasoning_required_for_tools.unwrap_or(false),
1205 reasoning_text_promotable: rule.reasoning_text_promotable.unwrap_or(false),
1206 reasoning_wire_format: rule
1207 .reasoning_wire_format
1208 .clone()
1209 .or_else(|| defaults.reasoning_wire_format.clone()),
1210 seed_supported: rule
1211 .seed_supported
1212 .or(defaults.seed_supported)
1213 .unwrap_or(true),
1214 top_k_supported: rule
1215 .top_k_supported
1216 .or(defaults.top_k_supported)
1217 .unwrap_or(true),
1218 temperature_supported: rule
1219 .temperature_supported
1220 .or(defaults.temperature_supported)
1221 .unwrap_or(true),
1222 top_p_supported: rule
1223 .top_p_supported
1224 .or(defaults.top_p_supported)
1225 .unwrap_or(true),
1226 frequency_penalty_supported: rule
1227 .frequency_penalty_supported
1228 .or(defaults.frequency_penalty_supported)
1229 .unwrap_or(true),
1230 presence_penalty_supported: rule
1231 .presence_penalty_supported
1232 .or(defaults.presence_penalty_supported)
1233 .unwrap_or(true),
1234 stop_supported: rule
1235 .stop_supported
1236 .or(defaults.stop_supported)
1237 .unwrap_or(true),
1238 allowed_tool_choice_modes: rule.allowed_tool_choice_modes.clone().unwrap_or_default(),
1239 requires_tool_result_adjacency: rule.requires_tool_result_adjacency.unwrap_or(false),
1240 supports_parallel_tool_calls: rule.supports_parallel_tool_calls.unwrap_or(true),
1241 tools_exclude_response_format: rule.tools_exclude_response_format.unwrap_or(false),
1242 recommended_endpoint: rule.recommended_endpoint.clone(),
1243 text_tool_wire_format_supported: rule.text_tool_wire_format_supported.unwrap_or(true),
1244 preferred_tool_format: Some(rule_preferred_tool_format(rule)),
1245 tool_mode_parity: Some(rule_tool_mode_parity(rule)),
1246 tool_mode_parity_notes: rule.tool_mode_parity_notes.clone(),
1247 thinking_disable_directive: rule.thinking_disable_directive.clone(),
1248 auto_reasoning_overrides: rule.auto_reasoning_overrides.clone().unwrap_or_default(),
1249 provider_route_denylist: rule.provider_route_denylist.clone().unwrap_or_default(),
1250 openrouter_provider_order: rule.openrouter_provider_order.clone().unwrap_or_default(),
1251 serving_precision: rule
1252 .serving_precision
1253 .clone()
1254 .unwrap_or_else(|| "unverified".to_string()),
1255 computer_use_style: rule.computer_use_style,
1256 screenshot_scaling: rule.screenshot_scaling,
1257 safety_ack_flow: rule.safety_ack_flow.unwrap_or(false),
1258 }
1259}
1260
1261pub(super) fn rule_preferred_tool_format(rule: &ProviderRule) -> String {
1262 rule.preferred_tool_format.clone().unwrap_or_else(|| {
1268 if rule.native_tools.unwrap_or(false) {
1269 "native".to_string()
1270 } else {
1271 "json".to_string()
1272 }
1273 })
1274}
1275
1276pub(super) fn rule_tool_mode_parity(rule: &ProviderRule) -> String {
1277 rule.tool_mode_parity.clone().unwrap_or_else(|| {
1278 match (
1279 rule.native_tools.unwrap_or(false),
1280 rule.text_tool_wire_format_supported.unwrap_or(true),
1281 ) {
1282 (true, true) => "unknown".to_string(),
1283 (true, false) => "native_only".to_string(),
1284 (false, true) => "text_only".to_string(),
1285 (false, false) => "unsupported".to_string(),
1286 }
1287 })
1288}
1289
1290pub(super) fn rule_structured_output(rule: &ProviderRule) -> Option<String> {
1291 rule.structured_output
1292 .clone()
1293 .or_else(|| rule.json_schema.clone())
1294 .filter(|value| value != "none")
1295}
1296
1297pub(super) fn rule_structured_output_mode(rule: &ProviderRule) -> String {
1298 if let Some(mode) = &rule.structured_output_mode {
1299 return mode.clone();
1300 }
1301 match rule_structured_output(rule).as_deref() {
1302 Some("native") | Some("format_kw") => "native_json".to_string(),
1303 Some("tool_use") => "xml_tagged".to_string(),
1304 _ => "none".to_string(),
1305 }
1306}
1307
1308pub(super) fn rule_thinking_block_style(rule: &ProviderRule) -> String {
1309 rule.thinking_block_style.clone().unwrap_or_else(|| {
1310 if rule.reasoning_effort_supported.unwrap_or(false)
1311 || rule.requires_completion_tokens.unwrap_or(false)
1312 {
1313 "reasoning_summary".to_string()
1314 } else {
1315 "none".to_string()
1316 }
1317 })
1318}
1319
1320pub(crate) fn rule_matches(rule: &ProviderRule, model: &str) -> bool {
1321 let lower = model.to_lowercase();
1322 if !glob_match(&rule.model_match.to_lowercase(), &lower) {
1323 return false;
1324 }
1325 if let Some(version_min) = &rule.version_min {
1326 if version_min.len() != 2 {
1327 return false;
1328 }
1329 let want = (version_min[0], version_min[1]);
1330 let have = match extract_version(model) {
1331 Some(v) => v,
1332 None => return false,
1336 };
1337 if have < want {
1338 return false;
1339 }
1340 }
1341 true
1342}
1343
1344fn extract_version(model: &str) -> Option<(u32, u32)> {
1349 claude_generation(model).or_else(|| gpt_generation(model))
1350}
1351
1352#[cfg(test)]
1353mod tests {
1354 use super::super::lookup::parse_capabilities_toml;
1355 use super::*;
1356
1357 #[test]
1358 fn glob_match_substring() {
1359 assert!(glob_match("*gpt*", "openai/gpt-5.4"));
1360 assert!(glob_match("*claude*", "anthropic/claude-opus-4-7"));
1361 assert!(!glob_match("*xyz*", "openai/gpt-5.4"));
1362 }
1363
1364 fn extends_caps(src: &str) -> Capabilities {
1368 let file = parse_capabilities_toml(src).expect("test capabilities toml parses");
1369 lookup_with("testprov", "test-model", &file, None)
1370 }
1371
1372 #[test]
1373 fn extends_rule_fills_unset_fields_from_later_matching_rule() {
1374 let caps = extends_caps(
1377 r#"
1378[[provider.testprov]]
1379model_match = "test-*"
1380extends = true
1381native_tools = true
1382
1383[[provider.testprov]]
1384model_match = "test-*"
1385vision = true
1386message_wire_format = "anthropic"
1387"#,
1388 );
1389 assert!(caps.native_tools, "field from the extends rule applies");
1390 assert!(
1391 caps.vision,
1392 "unset field filled from the later matching rule"
1393 );
1394 assert_eq!(caps.message_wire_format, WireDialect::Anthropic);
1395 }
1396
1397 #[test]
1398 fn non_extends_rule_terminates_resolution_unchanged() {
1399 let caps = extends_caps(
1403 r#"
1404[[provider.testprov]]
1405model_match = "test-*"
1406native_tools = true
1407
1408[[provider.testprov]]
1409model_match = "test-*"
1410vision = true
1411"#,
1412 );
1413 assert!(caps.native_tools);
1414 assert!(
1415 !caps.vision,
1416 "a non-extends first match must not absorb later rules"
1417 );
1418 }
1419
1420 #[test]
1421 fn extends_rule_does_not_override_explicitly_set_field() {
1422 let caps = extends_caps(
1426 r#"
1427[[provider.testprov]]
1428model_match = "test-*"
1429extends = true
1430native_tools = true
1431
1432[[provider.testprov]]
1433model_match = "test-*"
1434native_tools = false
1435vision = true
1436"#,
1437 );
1438 assert!(
1439 caps.native_tools,
1440 "the extends rule's explicit value is not overridden by a lower rule"
1441 );
1442 assert!(caps.vision, "still fills the field the chain left unset");
1443 }
1444
1445 #[test]
1446 fn extends_chain_falls_through_to_provider_defaults() {
1447 let caps = extends_caps(
1450 r#"
1451[provider_defaults.testprov]
1452seed_supported = true
1453
1454[[provider.testprov]]
1455model_match = "test-*"
1456extends = true
1457native_tools = true
1458"#,
1459 );
1460 assert!(caps.native_tools, "field from the extends rule applies");
1461 assert!(
1462 caps.seed_supported,
1463 "unset field filled from provider defaults"
1464 );
1465 }
1466}