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