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