Skip to main content

harn_vm/llm/
capabilities.rs

1//! Data-driven provider capabilities.
2//!
3//! The per-(provider, model) capability matrix (native tools, deferred
4//! tool loading, tool-search variants, prompt caching, extended thinking,
5//! max tool count) lives in the shipped `capabilities.toml` and is
6//! overridable per-project via `[[capabilities.provider.<name>]]` blocks
7//! in `harn.toml`. This module owns:
8//!
9//! - loading the built-in TOML (compiled in via `include_str!`);
10//! - merging user overrides on top;
11//! - matching a `(provider, model)` pair against the rule list with
12//!   glob + semver semantics;
13//! - exposing a stable `Capabilities` struct that the `LlmProvider`
14//!   trait delegates to as the single source of truth.
15//!
16//! Before this module the Anthropic / OpenAI gates were spread across
17//! `providers/anthropic.rs` and `providers/openai_compat.rs`. Their
18//! generation parsers are still used here for `version_min`, but the
19//! boolean gates that used to live alongside them are now data.
20
21use std::cell::RefCell;
22use std::collections::{BTreeMap, HashSet};
23use std::sync::OnceLock;
24
25use serde::{Deserialize, Serialize};
26
27use super::providers::anthropic::claude_generation;
28use super::providers::openai_compat::gpt_generation;
29
30/// Shipped default rules. Compiled into the binary at build time.
31const BUILTIN_TOML: &str = include_str!("capabilities.toml");
32const BUILTIN_PROVIDERS_TOML: &str = include_str!("providers.toml");
33
34/// Parsed on-disk capabilities schema. Public so harn-cli can
35/// construct one directly when wiring harn.toml overrides.
36#[derive(Debug, Clone, Deserialize, Default)]
37pub struct CapabilitiesFile {
38    /// Per-provider ordered rule lists. First matching rule wins.
39    #[serde(default)]
40    pub provider: BTreeMap<String, Vec<ProviderRule>>,
41    /// Per-provider defaults applied to every matching row and to
42    /// provider/model pairs that have no model-specific row. This keeps
43    /// transport-shape facts in data without repeating them on every
44    /// generation-specific capability row.
45    #[serde(default)]
46    pub provider_defaults: BTreeMap<String, ProviderDefaults>,
47    /// Sibling → canonical family mapping. Providers with no rule of
48    /// their own fall through to the named family (recursively).
49    #[serde(default)]
50    pub provider_family: BTreeMap<String, String>,
51}
52
53/// Provider-wide default fields merged into matching rules.
54#[derive(Debug, Clone, Deserialize, Default)]
55pub struct ProviderDefaults {
56    /// Message/request/response wire format used by shared helpers.
57    /// Known values are `openai`, `anthropic`, `gemini`, and `ollama`.
58    #[serde(default)]
59    pub message_wire_format: Option<String>,
60    /// Native tool definition wire shape. Known values are `openai`
61    /// and `anthropic`.
62    #[serde(default)]
63    pub native_tool_wire_format: Option<String>,
64    /// Whether image content blocks may reference remote URLs.
65    #[serde(default)]
66    pub image_url_input_supported: Option<bool>,
67    /// File-upload transport used by `std/files.upload`. Known values
68    /// are `anthropic` and `gemini`.
69    #[serde(default)]
70    pub file_upload_wire_format: Option<String>,
71    /// Provider-specific reasoning request shape for OpenAI-compatible
72    /// transports. Known values are `openrouter` and `enabled`.
73    #[serde(default)]
74    pub reasoning_wire_format: Option<String>,
75    #[serde(default)]
76    pub files_api_supported: Option<bool>,
77    #[serde(default)]
78    pub seed_supported: Option<bool>,
79    #[serde(default)]
80    pub top_k_supported: Option<bool>,
81    #[serde(default)]
82    pub frequency_penalty_supported: Option<bool>,
83    #[serde(default)]
84    pub presence_penalty_supported: Option<bool>,
85}
86
87impl ProviderDefaults {
88    fn overlay(&mut self, other: &ProviderDefaults) {
89        if other.message_wire_format.is_some() {
90            self.message_wire_format = other.message_wire_format.clone();
91        }
92        if other.native_tool_wire_format.is_some() {
93            self.native_tool_wire_format = other.native_tool_wire_format.clone();
94        }
95        if other.image_url_input_supported.is_some() {
96            self.image_url_input_supported = other.image_url_input_supported;
97        }
98        if other.file_upload_wire_format.is_some() {
99            self.file_upload_wire_format = other.file_upload_wire_format.clone();
100        }
101        if other.reasoning_wire_format.is_some() {
102            self.reasoning_wire_format = other.reasoning_wire_format.clone();
103        }
104        if other.files_api_supported.is_some() {
105            self.files_api_supported = other.files_api_supported;
106        }
107        if other.seed_supported.is_some() {
108            self.seed_supported = other.seed_supported;
109        }
110        if other.top_k_supported.is_some() {
111            self.top_k_supported = other.top_k_supported;
112        }
113        if other.frequency_penalty_supported.is_some() {
114            self.frequency_penalty_supported = other.frequency_penalty_supported;
115        }
116        if other.presence_penalty_supported.is_some() {
117            self.presence_penalty_supported = other.presence_penalty_supported;
118        }
119    }
120
121    fn fill_missing_from(&mut self, other: &ProviderDefaults) {
122        if self.message_wire_format.is_none() {
123            self.message_wire_format = other.message_wire_format.clone();
124        }
125        if self.native_tool_wire_format.is_none() {
126            self.native_tool_wire_format = other.native_tool_wire_format.clone();
127        }
128        if self.image_url_input_supported.is_none() {
129            self.image_url_input_supported = other.image_url_input_supported;
130        }
131        if self.file_upload_wire_format.is_none() {
132            self.file_upload_wire_format = other.file_upload_wire_format.clone();
133        }
134        if self.reasoning_wire_format.is_none() {
135            self.reasoning_wire_format = other.reasoning_wire_format.clone();
136        }
137        if self.files_api_supported.is_none() {
138            self.files_api_supported = other.files_api_supported;
139        }
140        if self.seed_supported.is_none() {
141            self.seed_supported = other.seed_supported;
142        }
143        if self.top_k_supported.is_none() {
144            self.top_k_supported = other.top_k_supported;
145        }
146        if self.frequency_penalty_supported.is_none() {
147            self.frequency_penalty_supported = other.frequency_penalty_supported;
148        }
149        if self.presence_penalty_supported.is_none() {
150            self.presence_penalty_supported = other.presence_penalty_supported;
151        }
152    }
153
154    fn has_any_field(&self) -> bool {
155        self.message_wire_format.is_some()
156            || self.native_tool_wire_format.is_some()
157            || self.image_url_input_supported.is_some()
158            || self.file_upload_wire_format.is_some()
159            || self.reasoning_wire_format.is_some()
160            || self.files_api_supported.is_some()
161            || self.seed_supported.is_some()
162            || self.top_k_supported.is_some()
163            || self.frequency_penalty_supported.is_some()
164            || self.presence_penalty_supported.is_some()
165    }
166}
167
168/// One row of the capability matrix.
169#[derive(Debug, Clone, Deserialize)]
170pub struct ProviderRule {
171    /// Glob pattern (supports leading / trailing `*` and a single mid-`*`).
172    /// Matched case-insensitively against the model ID.
173    pub model_match: String,
174    /// Optional `[major, minor]` lower bound. When set, the model ID
175    /// must parse via the provider's version extractor AND compare ≥
176    /// this tuple. Rules with an unparseable `version_min` for the
177    /// given model are skipped, not merged.
178    #[serde(default)]
179    pub version_min: Option<Vec<u32>>,
180    #[serde(default)]
181    pub native_tools: Option<bool>,
182    /// Message/request/response wire format used by shared helpers.
183    /// Known values are `openai`, `anthropic`, `gemini`, and `ollama`.
184    #[serde(default)]
185    pub message_wire_format: Option<String>,
186    /// Native tool definition wire shape. Known values are `openai`
187    /// and `anthropic`.
188    #[serde(default)]
189    pub native_tool_wire_format: Option<String>,
190    #[serde(default)]
191    pub defer_loading: Option<bool>,
192    #[serde(default)]
193    pub tool_search: Option<Vec<String>>,
194    /// Whether Harn supports this route through the provider's native
195    /// Responses-style API instead of generic chat completions.
196    #[serde(default)]
197    pub responses_api: Option<bool>,
198    /// Provider-hosted tools Harn can pass through without local execution.
199    #[serde(default)]
200    pub hosted_tools: Option<Vec<String>>,
201    /// Whether provider-hosted remote MCP connectors can be mediated by the
202    /// provider for this route.
203    #[serde(default)]
204    pub remote_mcp: Option<bool>,
205    /// Whether provider-managed previous-response conversation state is
206    /// available.
207    #[serde(default)]
208    pub conversation_state: Option<bool>,
209    /// Whether provider-side truncation/compaction controls are available.
210    #[serde(default)]
211    pub compaction: Option<bool>,
212    /// Whether provider-side background Responses jobs are available.
213    #[serde(default)]
214    pub background_mode: Option<bool>,
215    /// Approval policy modes available when provider-hosted tools execute.
216    #[serde(default)]
217    pub tool_approval_policy: Option<String>,
218    #[serde(default)]
219    pub max_tools: Option<u32>,
220    #[serde(default)]
221    pub prompt_caching: Option<bool>,
222    /// Whether this provider/model route accepts image or other visual
223    /// input blocks through Harn's LLM message path.
224    #[serde(default)]
225    pub vision: Option<bool>,
226    /// Whether this provider/model route accepts audio input blocks
227    /// through Harn's LLM message path.
228    #[serde(default, alias = "audio_supported")]
229    pub audio: Option<bool>,
230    /// Whether this provider/model route accepts PDF/document input blocks
231    /// through Harn's LLM message path.
232    #[serde(default, alias = "pdf_supported")]
233    pub pdf: Option<bool>,
234    /// Whether uploaded file references can be reused in message content.
235    #[serde(default)]
236    pub files_api_supported: Option<bool>,
237    /// File-upload transport used by `std/files.upload`. Known values
238    /// are `anthropic` and `gemini`.
239    #[serde(default)]
240    pub file_upload_wire_format: Option<String>,
241    /// Structured-output transport strategy. Known values are:
242    /// `native`, `tool_use`, `format_kw`, and `none`.
243    #[serde(default)]
244    pub structured_output: Option<String>,
245    /// Legacy name retained for project overrides written before
246    /// `structured_output` became the canonical capability.
247    #[serde(default)]
248    pub json_schema: Option<String>,
249    /// Whether prompt sections should prefer XML-style tags such as
250    /// `<task>` / `<examples>` over Markdown headings.
251    #[serde(default)]
252    pub prefers_xml_scaffolding: Option<bool>,
253    /// Whether prompt sections should prefer Markdown headings such as
254    /// `## Task` / `## Examples`.
255    #[serde(default)]
256    pub prefers_markdown_scaffolding: Option<bool>,
257    /// Preferred logical structured-output prompt shape. This is separate
258    /// from the transport-level `structured_output` strategy above.
259    /// Known values are `native_json`, `delimited`, and `xml_tagged`.
260    #[serde(default)]
261    pub structured_output_mode: Option<String>,
262    /// Whether the route accepts an assistant-role prefill message.
263    #[serde(default)]
264    pub supports_assistant_prefill: Option<bool>,
265    /// Whether durable instructions should use OpenAI's `developer` role
266    /// instead of `system`.
267    #[serde(default)]
268    pub prefers_role_developer: Option<bool>,
269    /// Whether text-rendered tool specifications should use XML wrappers
270    /// instead of JSON-schema prose.
271    #[serde(default)]
272    pub prefers_xml_tools: Option<bool>,
273    /// Preferred representation for model thinking/reasoning blocks in
274    /// transcript-like prompt context. Known values are `none`,
275    /// `thinking_blocks`, `reasoning_summary`, and `inline`.
276    #[serde(default)]
277    pub thinking_block_style: Option<String>,
278    /// Supported thinking/reasoning modes for this rule. Values are
279    /// script-facing mode names: `enabled`, `adaptive`, and `effort`.
280    #[serde(default)]
281    pub thinking_modes: Option<Vec<String>>,
282    /// Whether Anthropic interleaved thinking is supported for this
283    /// provider/model route.
284    #[serde(default)]
285    pub interleaved_thinking_supported: Option<bool>,
286    /// Anthropic beta features that should be requested for this route.
287    #[serde(default)]
288    pub anthropic_beta_features: Option<Vec<String>>,
289    /// Legacy override compatibility. New built-in rules should use
290    /// `thinking_modes` so the capability matrix preserves mode detail.
291    #[serde(default)]
292    pub thinking: Option<bool>,
293    /// Whether the model accepts image inputs in chat content.
294    #[serde(default)]
295    pub vision_supported: Option<bool>,
296    /// Whether image content blocks may reference remote URLs.
297    #[serde(default)]
298    pub image_url_input_supported: Option<bool>,
299    /// Carry `<think>...</think>` blocks in assistant history across turns.
300    /// Qwen3.6 exposes this as `chat_template_kwargs.preserve_thinking`;
301    /// Alibaba recommends enabling it for long-horizon agent loops so the
302    /// model doesn't re-derive context it already worked out in prior turns.
303    /// Anthropic's adaptive-thinking signature contract is stricter but plays
304    /// the same role there.
305    #[serde(default)]
306    pub preserve_thinking: Option<bool>,
307    /// Name of any server-side response parser that can transform model
308    /// bytes before Harn sees them. `none` means the provider returns the
309    /// model text/tool channel without an implicit parser.
310    #[serde(default)]
311    pub server_parser: Option<String>,
312    /// Whether provider-specific `chat_template_kwargs` are honored.
313    /// Some OpenAI-compatible servers silently drop unknown kwargs.
314    #[serde(default)]
315    pub honors_chat_template_kwargs: Option<bool>,
316    /// Whether this route requires OpenAI's `max_completion_tokens`
317    /// request field instead of legacy `max_tokens`.
318    #[serde(default)]
319    pub requires_completion_tokens: Option<bool>,
320    /// Whether this route accepts OpenAI's `reasoning_effort` request field.
321    #[serde(default)]
322    pub reasoning_effort_supported: Option<bool>,
323    /// Whether this route accepts `reasoning_effort: "none"` as a true
324    /// reasoning-off setting. Older GPT-5 variants support effort but only
325    /// floor at `minimal`.
326    #[serde(default)]
327    pub reasoning_none_supported: Option<bool>,
328    /// Provider-specific reasoning request shape for OpenAI-compatible
329    /// transports. Known values are `openrouter` and `enabled`.
330    #[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 frequency_penalty_supported: Option<bool>,
338    #[serde(default)]
339    pub presence_penalty_supported: Option<bool>,
340    /// Preferred endpoint family for this provider/model route. Values
341    /// are descriptive labels consumed by providers, e.g.
342    /// `/api/generate-raw` for Ollama raw prompt bypass.
343    #[serde(default)]
344    pub recommended_endpoint: Option<String>,
345    /// Whether Harn's text-tool protocol (`<tool_call>name({...})`) can
346    /// survive the provider route and return in the visible response body.
347    #[serde(default)]
348    pub text_tool_wire_format_supported: Option<bool>,
349    /// Preferred tool-calling mode for this provider/model route when
350    /// callers do not explicitly choose `tool_format`. This lets the
351    /// capability matrix route around known provider-native regressions
352    /// without making presets branch on model names.
353    #[serde(default)]
354    pub preferred_tool_format: Option<String>,
355    /// Empirical native/text interchangeability status for this route.
356    /// Known values are descriptive, not gates: `interchangeable`,
357    /// `native_unreliable`, `text_unreliable`, `native_only`,
358    /// `text_only`, and `unknown`.
359    #[serde(default)]
360    pub tool_mode_parity: Option<String>,
361    /// Short human-readable note explaining `tool_mode_parity`.
362    #[serde(default)]
363    pub tool_mode_parity_notes: Option<String>,
364    /// In-prompt directive that disables this model's "thinking" mode when
365    /// the API doesn't expose a first-class field (or exposes it
366    /// inconsistently across templates / quantizations). For Qwen3 family
367    /// chat templates this is `/no_think`. When `thinking: false` is
368    /// requested and this is set, Harn auto-prepends the directive to the
369    /// system message so script authors don't need to know it exists.
370    #[serde(default)]
371    pub thinking_disable_directive: Option<String>,
372    /// Per-task auto-policy reasoning-level overrides for this route.
373    /// Keys are task labels (`agent`, `verify`, `chat`, `summarize`,
374    /// `code`); values are reasoning levels (`off`, `minimal`, `low`,
375    /// `medium`, `high`, `xhigh`). Consulted by `reasoning_policy` only
376    /// when policy resolves to `auto` — explicit policies always win.
377    ///
378    /// Use this to declare known per-model regressions that should
379    /// flip the auto-policy default, instead of hard-coding the model/
380    /// provider pattern in resolver code. The canonical example is the
381    /// Qwen3 tool-call regression — `{ agent = "off" }` disables
382    /// reasoning whenever a script registers tools with that route,
383    /// matching Qwen's own published guidance.
384    #[serde(default)]
385    pub auto_reasoning_overrides: Option<BTreeMap<String, String>>,
386}
387
388/// Resolved capabilities for a `(provider, model)` pair. Unset rule
389/// fields resolve to `false` / empty / `None` so callers never have to
390/// unwrap an `Option<bool>` for what are really boolean gates.
391#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct Capabilities {
393    pub native_tools: bool,
394    pub message_wire_format: String,
395    pub native_tool_wire_format: String,
396    pub defer_loading: bool,
397    pub tool_search: Vec<String>,
398    pub responses_api: bool,
399    pub hosted_tools: Vec<String>,
400    pub remote_mcp: bool,
401    pub conversation_state: bool,
402    pub compaction: bool,
403    pub background_mode: bool,
404    pub tool_approval_policy: Option<String>,
405    pub max_tools: Option<u32>,
406    pub prompt_caching: bool,
407    pub vision: bool,
408    pub audio: bool,
409    pub pdf: bool,
410    pub files_api_supported: bool,
411    pub file_upload_wire_format: Option<String>,
412    pub structured_output: Option<String>,
413    /// Legacy mirror for CLI display and older callers.
414    pub json_schema: Option<String>,
415    pub prefers_xml_scaffolding: bool,
416    pub prefers_markdown_scaffolding: bool,
417    pub structured_output_mode: String,
418    pub supports_assistant_prefill: bool,
419    pub prefers_role_developer: bool,
420    pub prefers_xml_tools: bool,
421    pub thinking_block_style: String,
422    pub thinking_modes: Vec<String>,
423    pub interleaved_thinking_supported: bool,
424    pub anthropic_beta_features: Vec<String>,
425    pub vision_supported: bool,
426    pub image_url_input_supported: bool,
427    pub preserve_thinking: bool,
428    pub server_parser: String,
429    pub honors_chat_template_kwargs: bool,
430    pub requires_completion_tokens: bool,
431    pub reasoning_effort_supported: bool,
432    pub reasoning_none_supported: bool,
433    pub reasoning_wire_format: Option<String>,
434    pub seed_supported: bool,
435    pub top_k_supported: bool,
436    pub frequency_penalty_supported: bool,
437    pub presence_penalty_supported: bool,
438    pub recommended_endpoint: Option<String>,
439    pub text_tool_wire_format_supported: bool,
440    pub preferred_tool_format: Option<String>,
441    pub tool_mode_parity: Option<String>,
442    pub tool_mode_parity_notes: Option<String>,
443    pub thinking_disable_directive: Option<String>,
444    /// Per-task auto-policy reasoning-level overrides for this route.
445    /// See [`ProviderRule::auto_reasoning_overrides`].
446    pub auto_reasoning_overrides: BTreeMap<String, String>,
447}
448
449impl Default for Capabilities {
450    fn default() -> Self {
451        Self {
452            native_tools: false,
453            message_wire_format: "openai".to_string(),
454            native_tool_wire_format: "openai".to_string(),
455            defer_loading: false,
456            tool_search: Vec::new(),
457            responses_api: false,
458            hosted_tools: Vec::new(),
459            remote_mcp: false,
460            conversation_state: false,
461            compaction: false,
462            background_mode: false,
463            tool_approval_policy: None,
464            max_tools: None,
465            prompt_caching: false,
466            vision: false,
467            audio: false,
468            pdf: false,
469            files_api_supported: false,
470            file_upload_wire_format: None,
471            structured_output: None,
472            json_schema: None,
473            prefers_xml_scaffolding: false,
474            prefers_markdown_scaffolding: false,
475            structured_output_mode: "none".to_string(),
476            supports_assistant_prefill: false,
477            prefers_role_developer: false,
478            prefers_xml_tools: false,
479            thinking_block_style: "none".to_string(),
480            thinking_modes: Vec::new(),
481            interleaved_thinking_supported: false,
482            anthropic_beta_features: Vec::new(),
483            vision_supported: false,
484            image_url_input_supported: true,
485            preserve_thinking: false,
486            server_parser: "none".to_string(),
487            honors_chat_template_kwargs: false,
488            requires_completion_tokens: false,
489            reasoning_effort_supported: false,
490            reasoning_none_supported: false,
491            reasoning_wire_format: None,
492            seed_supported: true,
493            top_k_supported: true,
494            frequency_penalty_supported: true,
495            presence_penalty_supported: true,
496            recommended_endpoint: None,
497            text_tool_wire_format_supported: true,
498            preferred_tool_format: None,
499            tool_mode_parity: None,
500            tool_mode_parity_notes: None,
501            thinking_disable_directive: None,
502            auto_reasoning_overrides: BTreeMap::new(),
503        }
504    }
505}
506
507/// Display-oriented row for `harn providers matrix`, the legacy
508/// `harn check --provider-matrix` surface, and the generated docs page. Rows
509/// are intentionally rule-shaped: `model` is the rule's `model_match` pattern,
510/// because the shipped capability source of truth is a first-match rule table
511/// rather than an exhaustive remote model inventory.
512#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
513pub struct ProviderCapabilityMatrixRow {
514    pub provider: String,
515    pub model: String,
516    pub version_min: Option<Vec<u32>>,
517    pub thinking: Vec<String>,
518    pub vision: bool,
519    pub audio: bool,
520    pub pdf: bool,
521    pub streaming: bool,
522    pub files_api_supported: bool,
523    pub json_schema: Option<String>,
524    pub prefers_xml_scaffolding: bool,
525    pub prefers_markdown_scaffolding: bool,
526    pub structured_output_mode: String,
527    pub supports_assistant_prefill: bool,
528    pub prefers_role_developer: bool,
529    pub prefers_xml_tools: bool,
530    pub thinking_block_style: String,
531    pub native_tools: bool,
532    pub text_tools: bool,
533    pub preferred_tool_format: String,
534    pub tool_mode_parity: String,
535    pub tools: bool,
536    pub cache: bool,
537    pub source: String,
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
541pub struct ToolCapabilityAuditReport {
542    pub audited_models: usize,
543    pub gaps: Vec<ToolCapabilityAuditGap>,
544}
545
546impl ToolCapabilityAuditReport {
547    pub fn ok(&self) -> bool {
548        self.gaps.is_empty()
549    }
550
551    pub fn render_human(&self) -> String {
552        if self.gaps.is_empty() {
553            return format!(
554                "provider capability audit OK: {} priced chat models have explicit native_tools and preferred_tool_format rules",
555                self.audited_models
556            );
557        }
558
559        let mut out = format!(
560            "provider capability audit found {} catalog gaps among {} priced chat models:",
561            self.gaps.len(),
562            self.audited_models
563        );
564        for gap in &self.gaps {
565            let matched = match (&gap.rule_provider, &gap.rule_model_match) {
566                (Some(provider), Some(model_match)) => {
567                    format!("provider.{provider} model_match=\"{model_match}\"")
568                }
569                _ => "no matching rule".to_string(),
570            };
571            out.push_str(&format!(
572                "\n- {}:{} ({matched}) missing {}; suggest native_tools = {}, preferred_tool_format = \"{}\"",
573                gap.provider,
574                gap.model,
575                gap.missing_fields.join(", "),
576                gap.suggested_native_tools,
577                gap.suggested_preferred_tool_format,
578            ));
579        }
580        out
581    }
582}
583
584#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
585pub struct ToolCapabilityAuditGap {
586    pub provider: String,
587    pub model: String,
588    pub rule_provider: Option<String>,
589    pub rule_model_match: Option<String>,
590    pub missing_fields: Vec<String>,
591    pub suggested_native_tools: bool,
592    pub suggested_preferred_tool_format: String,
593}
594
595thread_local! {
596    /// Per-thread user overrides installed by the CLI at startup. Kept
597    /// thread-local (not process-static) to match the rest of the VM
598    /// state model — the VM is !Send and each VM thread owns its own
599    /// configuration.
600    static USER_OVERRIDES: RefCell<Option<CapabilitiesFile>> = const { RefCell::new(None) };
601}
602
603/// Lazily-parsed built-in rules. The `include_str!` content is a static
604/// constant; parsing it once per process is safe and free of ordering
605/// hazards.
606static BUILTIN: OnceLock<CapabilitiesFile> = OnceLock::new();
607
608fn builtin() -> &'static CapabilitiesFile {
609    BUILTIN.get_or_init(|| {
610        toml::from_str::<CapabilitiesFile>(BUILTIN_TOML)
611            .expect("capabilities.toml must parse at build time")
612    })
613}
614
615/// Install project-level overrides for the current thread. Usually
616/// called once at CLI bootstrap after reading `harn.toml`. Passing
617/// `None` clears any prior override.
618pub fn set_user_overrides(file: Option<CapabilitiesFile>) {
619    USER_OVERRIDES.with(|cell| *cell.borrow_mut() = file);
620}
621
622/// Clear any thread-local user overrides. Used between test runs.
623pub fn clear_user_overrides() {
624    set_user_overrides(None);
625}
626
627/// Parse a TOML string containing the capabilities section's own shape
628/// (i.e. top-level `[[provider.X]]` + optional `[provider_family]`, the
629/// same layout used by the built-in `capabilities.toml`) and install as
630/// the current thread's override.
631pub fn set_user_overrides_toml(src: &str) -> Result<(), String> {
632    let parsed: CapabilitiesFile = toml::from_str(src).map_err(|e| e.to_string())?;
633    set_user_overrides(Some(parsed));
634    Ok(())
635}
636
637/// Extract the `[capabilities]` section from a full `harn.toml` source
638/// and install it as the current thread's override. The schema inside
639/// that section mirrors `CapabilitiesFile` but with every key prefixed
640/// by `capabilities.`:
641///
642/// ```toml
643/// [[capabilities.provider.my-proxy]]
644/// model_match = "*"
645/// native_tools = true
646/// tool_search = ["hosted"]
647/// ```
648pub fn set_user_overrides_from_manifest_toml(src: &str) -> Result<(), String> {
649    #[derive(Deserialize)]
650    struct Manifest {
651        #[serde(default)]
652        capabilities: Option<CapabilitiesFile>,
653    }
654    let parsed: Manifest = toml::from_str(src).map_err(|e| e.to_string())?;
655    set_user_overrides(parsed.capabilities);
656    Ok(())
657}
658
659/// Look up effective capabilities for a `(provider, model)` pair.
660/// Walks the provider_family chain until it finds a rule list that
661/// matches. Within any one provider's rule list, user overrides are
662/// consulted before the built-in rules. The first matching rule wins —
663/// later rules (and later layers in the family chain) are ignored.
664pub fn lookup(provider: &str, model: &str) -> Capabilities {
665    let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
666    let mut caps = lookup_with(provider, model, builtin(), user.as_ref());
667    if provider != "openai" && provider != "mock" {
668        caps.responses_api = false;
669        caps.hosted_tools.clear();
670        caps.remote_mcp = false;
671        caps.conversation_state = false;
672        caps.compaction = false;
673        caps.background_mode = false;
674        caps.tool_approval_policy = None;
675    }
676    caps
677}
678
679/// Return the currently-effective provider capability rule matrix. User
680/// override rows, when installed for the current thread, are emitted before
681/// built-in rows so the display mirrors lookup precedence.
682pub fn matrix_rows() -> Vec<ProviderCapabilityMatrixRow> {
683    let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
684    let mut rows = Vec::new();
685    if let Some(user) = user.as_ref() {
686        push_matrix_rows(&mut rows, user, "project");
687    }
688    push_matrix_rows(&mut rows, builtin(), "builtin");
689    rows
690}
691
692/// Audit the currently effective provider/model catalog against the currently
693/// effective capability rules. This is the user-facing path used by the CLI
694/// when authors are adding provider catalog or capability override rows.
695pub fn audit_catalogued_chat_model_tool_capabilities() -> ToolCapabilityAuditReport {
696    let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
697    audit_tool_capability_coverage(
698        crate::llm_config::model_catalog_entries(),
699        builtin(),
700        user.as_ref(),
701    )
702}
703
704/// Audit the built-in catalog only. The CI test uses this path so external
705/// provider config cannot hide a gap in the shipped TOML assets.
706pub fn audit_builtin_catalogued_chat_model_tool_capabilities() -> ToolCapabilityAuditReport {
707    let catalog = crate::llm_config::parse_config_toml(BUILTIN_PROVIDERS_TOML)
708        .expect("providers.toml must parse at build time");
709    audit_tool_capability_coverage(catalog.models, builtin(), None)
710}
711
712fn audit_tool_capability_coverage<I>(
713    models: I,
714    builtin: &CapabilitiesFile,
715    user: Option<&CapabilitiesFile>,
716) -> ToolCapabilityAuditReport
717where
718    I: IntoIterator<Item = (String, crate::llm_config::ModelDef)>,
719{
720    let mut gaps = Vec::new();
721    let mut audited_models = 0;
722
723    for (model_id, model) in models {
724        if model.pricing.is_none() {
725            continue;
726        }
727        audited_models += 1;
728        let matched = first_matching_rule(user, builtin, &model.provider, &model_id);
729        let mut missing_fields = Vec::new();
730        match matched.as_ref().map(|matched| matched.rule) {
731            Some(rule) => {
732                if rule.native_tools.is_none() {
733                    missing_fields.push("native_tools".to_string());
734                }
735                if rule.preferred_tool_format.is_none() {
736                    missing_fields.push("preferred_tool_format".to_string());
737                }
738            }
739            None => {
740                missing_fields.push("native_tools".to_string());
741                missing_fields.push("preferred_tool_format".to_string());
742            }
743        }
744        if missing_fields.is_empty() {
745            continue;
746        }
747
748        let (suggested_native_tools, suggested_preferred_tool_format) =
749            suggested_tool_capability_defaults(
750                &model.provider,
751                &model_id,
752                &model,
753                matched.as_ref(),
754            );
755        gaps.push(ToolCapabilityAuditGap {
756            provider: model.provider,
757            model: model_id,
758            rule_provider: matched.as_ref().map(|matched| matched.provider.clone()),
759            rule_model_match: matched.map(|matched| matched.rule.model_match.clone()),
760            missing_fields,
761            suggested_native_tools,
762            suggested_preferred_tool_format,
763        });
764    }
765
766    gaps.sort_by(|left, right| {
767        left.provider
768            .cmp(&right.provider)
769            .then_with(|| left.model.cmp(&right.model))
770    });
771    ToolCapabilityAuditReport {
772        audited_models,
773        gaps,
774    }
775}
776
777struct MatchedCapabilityRule<'a> {
778    provider: String,
779    rule: &'a ProviderRule,
780}
781
782fn first_matching_rule<'a>(
783    user: Option<&'a CapabilitiesFile>,
784    builtin: &'a CapabilitiesFile,
785    provider: &str,
786    model: &str,
787) -> Option<MatchedCapabilityRule<'a>> {
788    let mut current = provider.to_string();
789    let mut visited = HashSet::new();
790    while visited.insert(current.clone()) {
791        if let Some(rule) = user
792            .and_then(|file| first_matching_rule_in_file(file, &current, model))
793            .or_else(|| first_matching_rule_in_file(builtin, &current, model))
794        {
795            return Some(MatchedCapabilityRule {
796                provider: current,
797                rule,
798            });
799        }
800        let next = user
801            .and_then(|file| file.provider_family.get(&current))
802            .or_else(|| builtin.provider_family.get(&current))
803            .cloned();
804        current = next?;
805    }
806    None
807}
808
809fn first_matching_rule_in_file<'a>(
810    file: &'a CapabilitiesFile,
811    provider: &str,
812    model: &str,
813) -> Option<&'a ProviderRule> {
814    file.provider
815        .get(provider)?
816        .iter()
817        .find(|rule| rule_matches(rule, model))
818}
819
820fn suggested_tool_capability_defaults(
821    provider: &str,
822    model_id: &str,
823    model: &crate::llm_config::ModelDef,
824    matched: Option<&MatchedCapabilityRule<'_>>,
825) -> (bool, String) {
826    if let Some(rule) = matched.map(|matched| matched.rule) {
827        let native_tools =
828            rule.native_tools
829                .unwrap_or_else(|| match rule.preferred_tool_format.as_deref() {
830                    Some("native") => true,
831                    Some("text") => false,
832                    _ => suggested_native_tools(provider, model_id, model),
833                });
834        let preferred_tool_format = rule
835            .preferred_tool_format
836            .clone()
837            .unwrap_or_else(|| tool_format_for_native(native_tools));
838        return (native_tools, preferred_tool_format);
839    }
840
841    let native_tools = suggested_native_tools(provider, model_id, model);
842    (native_tools, tool_format_for_native(native_tools))
843}
844
845fn suggested_native_tools(
846    provider: &str,
847    model_id: &str,
848    model: &crate::llm_config::ModelDef,
849) -> bool {
850    if provider == "anthropic" || model_id.contains("claude") {
851        return true;
852    }
853    if matches!(
854        provider,
855        "openai" | "gemini" | "cerebras" | "bedrock" | "azure_openai" | "vertex"
856    ) {
857        return true;
858    }
859    model
860        .capabilities
861        .iter()
862        .any(|capability| capability == "tools")
863}
864
865fn tool_format_for_native(native_tools: bool) -> String {
866    if native_tools {
867        "native".to_string()
868    } else {
869        "text".to_string()
870    }
871}
872
873fn push_matrix_rows(
874    rows: &mut Vec<ProviderCapabilityMatrixRow>,
875    file: &CapabilitiesFile,
876    source: &str,
877) {
878    for (provider, rules) in &file.provider {
879        for rule in rules {
880            rows.push(rule_to_matrix_row(provider, rule, source));
881        }
882    }
883}
884
885fn rule_to_matrix_row(
886    provider: &str,
887    rule: &ProviderRule,
888    source: &str,
889) -> ProviderCapabilityMatrixRow {
890    ProviderCapabilityMatrixRow {
891        provider: provider.to_string(),
892        model: rule.model_match.clone(),
893        version_min: rule.version_min.clone(),
894        thinking: rule_thinking_modes(rule),
895        vision: rule_vision(rule),
896        audio: rule.audio.unwrap_or(false),
897        pdf: rule.pdf.unwrap_or(false),
898        streaming: true,
899        files_api_supported: rule.files_api_supported.unwrap_or(false),
900        json_schema: rule_structured_output(rule),
901        prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
902        prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
903        structured_output_mode: rule_structured_output_mode(rule),
904        supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
905        prefers_role_developer: rule
906            .prefers_role_developer
907            .unwrap_or_else(|| rule.requires_completion_tokens.unwrap_or(false)),
908        prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
909        thinking_block_style: rule_thinking_block_style(rule),
910        native_tools: rule.native_tools.unwrap_or(false),
911        text_tools: rule.text_tool_wire_format_supported.unwrap_or(true),
912        preferred_tool_format: rule_preferred_tool_format(rule),
913        tool_mode_parity: rule_tool_mode_parity(rule),
914        tools: rule.native_tools.unwrap_or(false)
915            || rule.text_tool_wire_format_supported.unwrap_or(true),
916        cache: rule.prompt_caching.unwrap_or(false),
917        source: source.to_string(),
918    }
919}
920
921fn rule_thinking_modes(rule: &ProviderRule) -> Vec<String> {
922    rule.thinking_modes.clone().unwrap_or_else(|| {
923        if rule.thinking.unwrap_or(false) {
924            vec!["enabled".to_string()]
925        } else {
926            Vec::new()
927        }
928    })
929}
930
931fn rule_vision(rule: &ProviderRule) -> bool {
932    rule.vision.or(rule.vision_supported).unwrap_or(false)
933}
934
935fn lookup_with(
936    provider: &str,
937    model: &str,
938    builtin: &CapabilitiesFile,
939    user: Option<&CapabilitiesFile>,
940) -> Capabilities {
941    // Special case: mock spoofs either shape. Try anthropic first
942    // (Claude-shape model strings) so `mock` + `claude-opus-4-7`
943    // resolves to the Anthropic capability row — the same behaviour
944    // the hardcoded dispatch gave before this refactor. The native
945    // tool-definition wire shape is pinned to OpenAI so existing
946    // mock-based tests keep observing `t.function.name` regardless of
947    // which family's capability row matched; per-message wire format
948    // still tracks the matched family so Anthropic-specific request
949    // plumbing (beta headers, file-id passthrough) is exercised when
950    // a Claude model is mocked.
951    if provider == "mock" {
952        let anthropic_defaults = merged_provider_defaults(user, builtin, "anthropic");
953        if let Some(mut caps) =
954            try_match_layer(user, builtin, "anthropic", model, &anthropic_defaults)
955        {
956            caps.native_tool_wire_format = "openai".to_string();
957            return caps;
958        }
959        let openai_defaults = merged_provider_defaults(user, builtin, "openai");
960        if let Some(caps) = try_match_layer(user, builtin, "openai", model, &openai_defaults) {
961            return caps;
962        }
963        let gemini_defaults = merged_provider_defaults(user, builtin, "gemini");
964        if let Some(caps) = try_match_layer(user, builtin, "gemini", model, &gemini_defaults) {
965            return caps;
966        }
967        return Capabilities::default();
968    }
969
970    // Normal chain: walk provider → family(provider) → ... with a
971    // visited-guard to avoid cycles in malformed user overrides.
972    let mut current = provider.to_string();
973    let mut effective_defaults = ProviderDefaults::default();
974    let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
975    while visited.insert(current.clone()) {
976        let layer_defaults = merged_provider_defaults(user, builtin, &current);
977        if effective_defaults.has_any_field() {
978            effective_defaults.fill_missing_from(&layer_defaults);
979        } else {
980            effective_defaults.overlay(&layer_defaults);
981        }
982        if let Some(caps) = try_match_layer(user, builtin, &current, model, &effective_defaults) {
983            return caps;
984        }
985        let next = user
986            .and_then(|f| f.provider_family.get(&current))
987            .or_else(|| builtin.provider_family.get(&current))
988            .cloned();
989        match next {
990            Some(parent) => current = parent,
991            None => break,
992        }
993    }
994    if effective_defaults.has_any_field() {
995        return defaults_to_caps(&effective_defaults);
996    }
997    Capabilities::default()
998}
999
1000/// Try the ordered rule list for `layer_provider` (user rules first,
1001/// then built-in rules). Returns `Some(caps)` on the first match, else
1002/// `None`. `original_provider` is threaded through only for diagnostics.
1003fn try_match_layer(
1004    user: Option<&CapabilitiesFile>,
1005    builtin: &CapabilitiesFile,
1006    layer_provider: &str,
1007    model: &str,
1008    defaults: &ProviderDefaults,
1009) -> Option<Capabilities> {
1010    if let Some(user) = user {
1011        if let Some(rules) = user.provider.get(layer_provider) {
1012            for rule in rules {
1013                if rule_matches(rule, model) {
1014                    return Some(rule_to_caps(rule, defaults));
1015                }
1016            }
1017        }
1018    }
1019    if let Some(rules) = builtin.provider.get(layer_provider) {
1020        for rule in rules {
1021            if rule_matches(rule, model) {
1022                return Some(rule_to_caps(rule, defaults));
1023            }
1024        }
1025    }
1026    None
1027}
1028
1029fn merged_provider_defaults(
1030    user: Option<&CapabilitiesFile>,
1031    builtin: &CapabilitiesFile,
1032    provider: &str,
1033) -> ProviderDefaults {
1034    let mut defaults = builtin
1035        .provider_defaults
1036        .get(provider)
1037        .cloned()
1038        .unwrap_or_default();
1039    if let Some(user_defaults) = user.and_then(|file| file.provider_defaults.get(provider)) {
1040        defaults.overlay(user_defaults);
1041    }
1042    defaults
1043}
1044
1045fn defaults_to_caps(defaults: &ProviderDefaults) -> Capabilities {
1046    let empty = ProviderRule {
1047        model_match: "*".to_string(),
1048        version_min: None,
1049        native_tools: None,
1050        message_wire_format: None,
1051        native_tool_wire_format: None,
1052        defer_loading: None,
1053        tool_search: None,
1054        responses_api: None,
1055        hosted_tools: None,
1056        remote_mcp: None,
1057        conversation_state: None,
1058        compaction: None,
1059        background_mode: None,
1060        tool_approval_policy: None,
1061        max_tools: None,
1062        prompt_caching: None,
1063        vision: None,
1064        audio: None,
1065        pdf: None,
1066        files_api_supported: None,
1067        file_upload_wire_format: None,
1068        structured_output: None,
1069        prefers_xml_scaffolding: None,
1070        prefers_markdown_scaffolding: None,
1071        structured_output_mode: None,
1072        supports_assistant_prefill: None,
1073        prefers_role_developer: None,
1074        prefers_xml_tools: None,
1075        thinking_block_style: None,
1076        json_schema: None,
1077        thinking_modes: None,
1078        interleaved_thinking_supported: None,
1079        anthropic_beta_features: None,
1080        thinking: None,
1081        vision_supported: None,
1082        image_url_input_supported: None,
1083        preserve_thinking: None,
1084        server_parser: None,
1085        honors_chat_template_kwargs: None,
1086        requires_completion_tokens: None,
1087        reasoning_effort_supported: None,
1088        reasoning_none_supported: None,
1089        reasoning_wire_format: None,
1090        seed_supported: None,
1091        top_k_supported: None,
1092        frequency_penalty_supported: None,
1093        presence_penalty_supported: None,
1094        recommended_endpoint: None,
1095        text_tool_wire_format_supported: None,
1096        preferred_tool_format: None,
1097        tool_mode_parity: None,
1098        tool_mode_parity_notes: None,
1099        thinking_disable_directive: None,
1100        auto_reasoning_overrides: None,
1101    };
1102    let mut caps = rule_to_caps(&empty, defaults);
1103    caps.preferred_tool_format = None;
1104    caps.tool_mode_parity = None;
1105    caps
1106}
1107
1108fn rule_to_caps(rule: &ProviderRule, defaults: &ProviderDefaults) -> Capabilities {
1109    let thinking_modes = rule_thinking_modes(rule);
1110    Capabilities {
1111        native_tools: rule.native_tools.unwrap_or(false),
1112        message_wire_format: rule
1113            .message_wire_format
1114            .clone()
1115            .or_else(|| defaults.message_wire_format.clone())
1116            .unwrap_or_else(|| "openai".to_string()),
1117        native_tool_wire_format: rule
1118            .native_tool_wire_format
1119            .clone()
1120            .or_else(|| defaults.native_tool_wire_format.clone())
1121            .unwrap_or_else(|| "openai".to_string()),
1122        defer_loading: rule.defer_loading.unwrap_or(false),
1123        tool_search: rule.tool_search.clone().unwrap_or_default(),
1124        responses_api: rule.responses_api.unwrap_or(false),
1125        hosted_tools: rule.hosted_tools.clone().unwrap_or_default(),
1126        remote_mcp: rule.remote_mcp.unwrap_or(false),
1127        conversation_state: rule.conversation_state.unwrap_or(false),
1128        compaction: rule.compaction.unwrap_or(false),
1129        background_mode: rule.background_mode.unwrap_or(false),
1130        tool_approval_policy: rule.tool_approval_policy.clone(),
1131        max_tools: rule.max_tools,
1132        prompt_caching: rule.prompt_caching.unwrap_or(false),
1133        vision: rule_vision(rule),
1134        audio: rule.audio.unwrap_or(false),
1135        pdf: rule.pdf.unwrap_or(false),
1136        files_api_supported: rule
1137            .files_api_supported
1138            .or(defaults.files_api_supported)
1139            .unwrap_or(false),
1140        file_upload_wire_format: rule
1141            .file_upload_wire_format
1142            .clone()
1143            .or_else(|| defaults.file_upload_wire_format.clone()),
1144        structured_output: rule_structured_output(rule),
1145        json_schema: rule_structured_output(rule),
1146        prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
1147        prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
1148        structured_output_mode: rule_structured_output_mode(rule),
1149        supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
1150        prefers_role_developer: rule.prefers_role_developer.unwrap_or(false),
1151        prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
1152        thinking_block_style: rule_thinking_block_style(rule),
1153        thinking_modes,
1154        interleaved_thinking_supported: rule.interleaved_thinking_supported.unwrap_or(false),
1155        anthropic_beta_features: rule.anthropic_beta_features.clone().unwrap_or_default(),
1156        vision_supported: rule.vision_supported.unwrap_or(false),
1157        image_url_input_supported: rule
1158            .image_url_input_supported
1159            .or(defaults.image_url_input_supported)
1160            .unwrap_or(true),
1161        preserve_thinking: rule.preserve_thinking.unwrap_or(false),
1162        server_parser: rule
1163            .server_parser
1164            .clone()
1165            .unwrap_or_else(|| "none".to_string()),
1166        honors_chat_template_kwargs: rule.honors_chat_template_kwargs.unwrap_or(false),
1167        requires_completion_tokens: rule.requires_completion_tokens.unwrap_or(false),
1168        reasoning_effort_supported: rule.reasoning_effort_supported.unwrap_or(false),
1169        reasoning_none_supported: rule.reasoning_none_supported.unwrap_or(false),
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        frequency_penalty_supported: rule
1183            .frequency_penalty_supported
1184            .or(defaults.frequency_penalty_supported)
1185            .unwrap_or(true),
1186        presence_penalty_supported: rule
1187            .presence_penalty_supported
1188            .or(defaults.presence_penalty_supported)
1189            .unwrap_or(true),
1190        recommended_endpoint: rule.recommended_endpoint.clone(),
1191        text_tool_wire_format_supported: rule.text_tool_wire_format_supported.unwrap_or(true),
1192        preferred_tool_format: Some(rule_preferred_tool_format(rule)),
1193        tool_mode_parity: Some(rule_tool_mode_parity(rule)),
1194        tool_mode_parity_notes: rule.tool_mode_parity_notes.clone(),
1195        thinking_disable_directive: rule.thinking_disable_directive.clone(),
1196        auto_reasoning_overrides: rule.auto_reasoning_overrides.clone().unwrap_or_default(),
1197    }
1198}
1199
1200fn rule_preferred_tool_format(rule: &ProviderRule) -> String {
1201    rule.preferred_tool_format.clone().unwrap_or_else(|| {
1202        if rule.native_tools.unwrap_or(false) {
1203            "native".to_string()
1204        } else {
1205            "text".to_string()
1206        }
1207    })
1208}
1209
1210fn rule_tool_mode_parity(rule: &ProviderRule) -> String {
1211    rule.tool_mode_parity.clone().unwrap_or_else(|| {
1212        match (
1213            rule.native_tools.unwrap_or(false),
1214            rule.text_tool_wire_format_supported.unwrap_or(true),
1215        ) {
1216            (true, true) => "unknown".to_string(),
1217            (true, false) => "native_only".to_string(),
1218            (false, true) => "text_only".to_string(),
1219            (false, false) => "unsupported".to_string(),
1220        }
1221    })
1222}
1223
1224fn rule_structured_output(rule: &ProviderRule) -> Option<String> {
1225    rule.structured_output
1226        .clone()
1227        .or_else(|| rule.json_schema.clone())
1228        .filter(|value| value != "none")
1229}
1230
1231fn rule_structured_output_mode(rule: &ProviderRule) -> String {
1232    if let Some(mode) = &rule.structured_output_mode {
1233        return mode.clone();
1234    }
1235    match rule_structured_output(rule).as_deref() {
1236        Some("native") | Some("format_kw") => "native_json".to_string(),
1237        Some("tool_use") => "xml_tagged".to_string(),
1238        _ => "none".to_string(),
1239    }
1240}
1241
1242fn rule_thinking_block_style(rule: &ProviderRule) -> String {
1243    rule.thinking_block_style.clone().unwrap_or_else(|| {
1244        if rule.reasoning_effort_supported.unwrap_or(false)
1245            || rule.requires_completion_tokens.unwrap_or(false)
1246        {
1247            "reasoning_summary".to_string()
1248        } else {
1249            "none".to_string()
1250        }
1251    })
1252}
1253
1254fn rule_matches(rule: &ProviderRule, model: &str) -> bool {
1255    let lower = model.to_lowercase();
1256    if !glob_match(&rule.model_match.to_lowercase(), &lower) {
1257        return false;
1258    }
1259    if let Some(version_min) = &rule.version_min {
1260        if version_min.len() != 2 {
1261            return false;
1262        }
1263        let want = (version_min[0], version_min[1]);
1264        let have = match extract_version(model) {
1265            Some(v) => v,
1266            // `version_min` was set but the model ID can't be parsed.
1267            // Fail closed: skip this rule so more permissive catch-all
1268            // rules below can still match.
1269            None => return false,
1270        };
1271        if have < want {
1272            return false;
1273        }
1274    }
1275    true
1276}
1277
1278/// Extract `(major, minor)` from a model ID by trying the Anthropic
1279/// parser first (for `claude-*` shapes) then the OpenAI parser (`gpt-*`).
1280/// Both parsers return `None` for shapes they don't recognise so this
1281/// never mis-parses across families.
1282fn extract_version(model: &str) -> Option<(u32, u32)> {
1283    claude_generation(model).or_else(|| gpt_generation(model))
1284}
1285
1286/// Simple glob matching with `*` wildcards. Mirrors the helper in
1287/// `llm_config.rs` — keep them in sync if either ever grows regex or
1288/// character-class support.
1289fn glob_match(pattern: &str, input: &str) -> bool {
1290    if let Some(prefix) = pattern.strip_suffix('*') {
1291        if let Some(rest) = prefix.strip_prefix('*') {
1292            // `*foo*` — substring match.
1293            return input.contains(rest);
1294        }
1295        return input.starts_with(prefix);
1296    }
1297    if let Some(suffix) = pattern.strip_prefix('*') {
1298        return input.ends_with(suffix);
1299    }
1300    if pattern.contains('*') {
1301        let parts: Vec<&str> = pattern.split('*').collect();
1302        if parts.len() == 2 {
1303            return input.starts_with(parts[0]) && input.ends_with(parts[1]);
1304        }
1305        return input == pattern;
1306    }
1307    input == pattern
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    use super::*;
1313
1314    fn reset() {
1315        clear_user_overrides();
1316    }
1317
1318    #[test]
1319    fn every_catalogued_chat_model_has_explicit_tool_capabilities() {
1320        reset();
1321        let report = audit_builtin_catalogued_chat_model_tool_capabilities();
1322        assert!(report.ok(), "{}", report.render_human());
1323    }
1324
1325    #[test]
1326    fn tool_capability_audit_reports_suggested_defaults() {
1327        reset();
1328        let capabilities: CapabilitiesFile = toml::from_str(
1329            r#"
1330[[provider.acme]]
1331model_match = "acme-good-*"
1332preferred_tool_format = "native"
1333"#,
1334        )
1335        .unwrap();
1336        let report = audit_tool_capability_coverage(
1337            vec![(
1338                "acme-good-1".to_string(),
1339                crate::llm_config::ModelDef {
1340                    name: "Acme Good".to_string(),
1341                    provider: "acme".to_string(),
1342                    context_window: 128_000,
1343                    runtime_context_window: None,
1344                    stream_timeout: None,
1345                    capabilities: Vec::new(),
1346                    pricing: Some(crate::llm_config::ModelPricing {
1347                        input_per_mtok: 1.0,
1348                        output_per_mtok: 2.0,
1349                        cache_read_per_mtok: None,
1350                        cache_write_per_mtok: None,
1351                    }),
1352                    deprecated: false,
1353                    deprecation_note: None,
1354                    superseded_by: None,
1355                    fast_mode: None,
1356                    quality_tags: Vec::new(),
1357                    availability: crate::llm_config::ModelAvailability::Serverless,
1358                    tier: None,
1359                    open_weight: None,
1360                    strengths: Vec::new(),
1361                    benchmarks: std::collections::BTreeMap::new(),
1362                },
1363            )],
1364            &capabilities,
1365            None,
1366        );
1367
1368        assert!(!report.ok());
1369        assert_eq!(report.audited_models, 1);
1370        assert_eq!(report.gaps.len(), 1);
1371        assert_eq!(report.gaps[0].missing_fields, ["native_tools"]);
1372        assert!(report.gaps[0].suggested_native_tools);
1373        assert_eq!(report.gaps[0].suggested_preferred_tool_format, "native");
1374        assert!(report.render_human().contains(
1375            "acme:acme-good-1 (provider.acme model_match=\"acme-good-*\") missing native_tools; suggest native_tools = true, preferred_tool_format = \"native\""
1376        ));
1377    }
1378
1379    #[test]
1380    fn anthropic_opus_47_gets_full_capabilities() {
1381        reset();
1382        let caps = lookup("anthropic", "claude-opus-4-7");
1383        assert!(caps.native_tools);
1384        assert!(caps.defer_loading);
1385        assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
1386        assert!(caps.prompt_caching);
1387        assert_eq!(caps.thinking_modes, vec!["adaptive"]);
1388        assert!(caps.vision_supported);
1389        assert!(caps.audio);
1390        assert!(caps.pdf);
1391        assert!(caps.files_api_supported);
1392        assert_eq!(caps.max_tools, Some(10000));
1393        assert!(caps.prefers_xml_scaffolding);
1394        assert!(!caps.prefers_markdown_scaffolding);
1395        assert_eq!(caps.structured_output_mode, "xml_tagged");
1396        assert!(!caps.supports_assistant_prefill);
1397        assert!(!caps.prefers_role_developer);
1398        assert!(caps.prefers_xml_tools);
1399        assert_eq!(caps.thinking_block_style, "thinking_blocks");
1400    }
1401
1402    #[test]
1403    fn anthropic_opus_46_uses_budgeted_thinking() {
1404        reset();
1405        let caps = lookup("anthropic", "claude-opus-4-6");
1406        assert_eq!(caps.thinking_modes, vec!["enabled"]);
1407        assert!(caps.interleaved_thinking_supported);
1408        assert!(!caps.supports_assistant_prefill);
1409    }
1410
1411    #[test]
1412    fn anthropic_opus_45_does_not_support_interleaved_thinking() {
1413        reset();
1414        let caps = lookup("anthropic", "claude-opus-4-5");
1415        assert_eq!(caps.thinking_modes, vec!["enabled"]);
1416        assert!(!caps.interleaved_thinking_supported);
1417        assert!(caps.supports_assistant_prefill);
1418    }
1419
1420    #[test]
1421    fn override_can_supply_anthropic_beta_features() {
1422        reset();
1423        let toml_src = r#"
1424[[provider.anthropic]]
1425model_match = "claude-custom-*"
1426native_tools = true
1427anthropic_beta_features = ["fine-grained-tool-streaming-2025-05-14"]
1428"#;
1429        set_user_overrides_toml(toml_src).unwrap();
1430        let caps = lookup("anthropic", "claude-custom-1");
1431        assert_eq!(
1432            caps.anthropic_beta_features,
1433            vec!["fine-grained-tool-streaming-2025-05-14"]
1434        );
1435        reset();
1436    }
1437
1438    #[test]
1439    fn anthropic_haiku_44_has_no_tool_search() {
1440        reset();
1441        let caps = lookup("anthropic", "claude-haiku-4-4");
1442        // Haiku 4.4 falls through to the `claude-*` catch-all row.
1443        assert!(caps.native_tools);
1444        assert!(caps.prompt_caching);
1445        assert!(!caps.defer_loading);
1446        assert!(caps.tool_search.is_empty());
1447    }
1448
1449    #[test]
1450    fn anthropic_haiku_45_supports_tool_search() {
1451        reset();
1452        let caps = lookup("anthropic", "claude-haiku-4-5");
1453        assert!(caps.defer_loading);
1454        assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
1455    }
1456
1457    #[test]
1458    fn old_claude_gets_catchall() {
1459        reset();
1460        let caps = lookup("anthropic", "claude-opus-3-5");
1461        assert!(caps.native_tools);
1462        assert!(caps.prompt_caching);
1463        assert!(!caps.defer_loading);
1464        assert!(caps.tool_search.is_empty());
1465    }
1466
1467    #[test]
1468    fn openai_gpt_54_supports_tool_search() {
1469        reset();
1470        let caps = lookup("openai", "gpt-5.4");
1471        assert!(caps.defer_loading);
1472        assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1473        assert_eq!(caps.json_schema.as_deref(), Some("native"));
1474        assert_eq!(caps.thinking_modes, vec!["effort"]);
1475        assert!(caps.reasoning_effort_supported);
1476        assert!(caps.reasoning_none_supported);
1477        assert!(!caps.prefers_xml_scaffolding);
1478        assert!(caps.prefers_markdown_scaffolding);
1479        assert_eq!(caps.structured_output_mode, "native_json");
1480        assert!(!caps.supports_assistant_prefill);
1481        assert!(!caps.prefers_role_developer);
1482        assert!(!caps.prefers_xml_tools);
1483        assert_eq!(caps.thinking_block_style, "reasoning_summary");
1484    }
1485
1486    #[test]
1487    fn openai_gpt_53_has_reasoning_none_without_tool_search() {
1488        reset();
1489        let caps = lookup("openai", "gpt-5.3");
1490        assert!(caps.native_tools);
1491        assert!(!caps.defer_loading);
1492        assert!(caps.vision_supported);
1493        assert!(caps.tool_search.is_empty());
1494        assert_eq!(caps.thinking_modes, vec!["effort"]);
1495        assert!(caps.reasoning_effort_supported);
1496        assert!(caps.reasoning_none_supported);
1497    }
1498
1499    #[test]
1500    fn openai_original_gpt_5_has_reasoning_floor_without_none() {
1501        reset();
1502        let caps = lookup("openai", "gpt-5");
1503        assert!(caps.native_tools);
1504        assert!(!caps.defer_loading);
1505        assert_eq!(caps.thinking_modes, vec!["effort"]);
1506        assert!(caps.reasoning_effort_supported);
1507        assert!(!caps.reasoning_none_supported);
1508    }
1509
1510    #[test]
1511    fn openai_gpt_4o_matrix_fields_include_multimodal_support() {
1512        reset();
1513        let caps = lookup("openai", "gpt-4o");
1514        assert!(caps.native_tools);
1515        assert!(caps.vision);
1516        assert!(caps.audio);
1517        assert!(!caps.pdf);
1518        assert_eq!(caps.json_schema.as_deref(), Some("native"));
1519    }
1520
1521    #[test]
1522    fn openai_reasoning_models_support_effort() {
1523        reset();
1524        let caps = lookup("openai", "o3");
1525        assert_eq!(caps.thinking_modes, vec!["effort"]);
1526        assert!(caps.requires_completion_tokens);
1527        assert!(caps.reasoning_effort_supported);
1528        assert!(caps.prefers_role_developer);
1529        assert_eq!(caps.thinking_block_style, "reasoning_summary");
1530        let prefixed = lookup("openrouter", "openai/o4-mini");
1531        assert!(prefixed.requires_completion_tokens);
1532        assert!(prefixed.reasoning_effort_supported);
1533    }
1534
1535    #[test]
1536    fn vision_capability_gates_known_multimodal_models() {
1537        reset();
1538        assert!(lookup("openai", "gpt-4o").vision_supported);
1539        assert!(lookup("openai", "gpt-5.4-preview").vision_supported);
1540        assert!(lookup("anthropic", "claude-sonnet-4-6").vision_supported);
1541        assert!(lookup("anthropic", "claude-sonnet-4-6").pdf);
1542        assert!(lookup("anthropic", "claude-sonnet-4-6").files_api_supported);
1543        assert!(lookup("openrouter", "google/gemini-2.5-flash").vision_supported);
1544        assert!(lookup("gemini", "gemini-2.5-flash").vision_supported);
1545        assert!(lookup("gemini", "gemini-2.5-flash").audio);
1546        assert!(lookup("gemini", "gemini-2.5-flash").pdf);
1547        assert_eq!(
1548            lookup("gemini", "gemini-2.5-flash").structured_output_mode,
1549            "native_json"
1550        );
1551        assert!(lookup("ollama", "llava:latest").vision_supported);
1552        assert!(lookup("ollama", "gemma4:26b").vision_supported);
1553        assert!(lookup("ollama", "gemma4-128k:latest").vision_supported);
1554        assert!(!lookup("openai", "gpt-3.5-turbo").vision_supported);
1555        assert!(!lookup("ollama", "qwen3.5:35b-a3b-coding-nvfp4").vision_supported);
1556    }
1557
1558    #[test]
1559    fn openrouter_inherits_openai() {
1560        reset();
1561        let caps = lookup("openrouter", "gpt-5.4");
1562        assert!(caps.defer_loading);
1563        assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1564        assert_eq!(caps.reasoning_wire_format.as_deref(), Some("openrouter"));
1565        assert!(!caps.top_k_supported);
1566    }
1567
1568    #[test]
1569    fn openrouter_structured_routes_cover_current_open_models() {
1570        reset();
1571        for model in [
1572            "deepseek/deepseek-v4-flash",
1573            "mistralai/devstral-small",
1574            "meta-llama/llama-4-scout",
1575        ] {
1576            let caps = lookup("openrouter", model);
1577            assert!(caps.native_tools, "{model} should expose native tools");
1578            assert_eq!(caps.structured_output.as_deref(), Some("native"));
1579            assert_eq!(caps.structured_output_mode, "native_json");
1580        }
1581        assert!(lookup("openrouter", "deepseek/deepseek-v4-flash").top_k_supported);
1582        assert!(lookup("openrouter", "meta-llama/llama-4-scout").top_k_supported);
1583        assert!(!lookup("openrouter", "mistralai/devstral-small").top_k_supported);
1584        assert!(lookup("openrouter", "google/gemma-4-26b-a4b-it").top_k_supported);
1585    }
1586
1587    #[test]
1588    fn openrouter_anthropic_claude_models_support_native_tools() {
1589        // Regression for #2319: without explicit openrouter rules,
1590        // openrouter:anthropic/claude-* used to fall through the
1591        // openrouter→openai family chain and miss the [[provider.anthropic]]
1592        // matchers entirely, so native-tool requests HTTP 400'd with
1593        // "option `tools` is not supported by ... (provider openrouter)".
1594        reset();
1595        for model in [
1596            "anthropic/claude-haiku-4-5",
1597            "anthropic/claude-haiku-4-5-20251001",
1598            "anthropic/claude-sonnet-4-6",
1599            "anthropic/claude-sonnet-4-7",
1600            "anthropic/claude-opus-4-7",
1601        ] {
1602            let caps = lookup("openrouter", model);
1603            assert!(
1604                caps.native_tools,
1605                "{model} via openrouter should report native_tools=true",
1606            );
1607            assert!(
1608                caps.prompt_caching,
1609                "{model} via openrouter should report prompt_caching=true",
1610            );
1611            assert_eq!(
1612                caps.structured_output.as_deref(),
1613                Some("tool_use"),
1614                "{model} via openrouter should structured_output=tool_use (matches direct anthropic)",
1615            );
1616        }
1617    }
1618
1619    #[test]
1620    fn openrouter_deepseek_v32_defaults_to_text_tools() {
1621        reset();
1622        let caps = lookup("openrouter", "deepseek/deepseek-v3.2");
1623        assert!(caps.native_tools);
1624        assert!(caps.text_tool_wire_format_supported);
1625        assert_eq!(caps.preferred_tool_format.as_deref(), Some("text"));
1626        assert_eq!(caps.tool_mode_parity.as_deref(), Some("native_unreliable"));
1627        assert_eq!(caps.structured_output.as_deref(), Some("native"));
1628    }
1629
1630    #[test]
1631    fn openrouter_qwen_coder_defaults_to_text_tools() {
1632        reset();
1633        let caps = lookup("openrouter", "qwen/qwen3-coder-flash");
1634        assert!(caps.native_tools);
1635        assert!(caps.text_tool_wire_format_supported);
1636        assert_eq!(caps.preferred_tool_format.as_deref(), Some("text"));
1637        assert_eq!(caps.tool_mode_parity.as_deref(), Some("native_unreliable"));
1638    }
1639
1640    #[test]
1641    fn bedrock_claude_uses_anthropic_wire_capabilities() {
1642        reset();
1643        let caps = lookup("bedrock", "anthropic.claude-3-5-sonnet-20240620-v1:0");
1644        assert!(caps.native_tools);
1645        assert_eq!(caps.message_wire_format, "anthropic");
1646        assert_eq!(caps.native_tool_wire_format, "anthropic");
1647    }
1648
1649    #[test]
1650    fn groq_inherits_openai_family_only() {
1651        reset();
1652        let caps = lookup("groq", "gpt-5.5-preview");
1653        assert!(caps.defer_loading);
1654    }
1655
1656    #[test]
1657    fn cerebras_inherits_openai_family() {
1658        reset();
1659        let caps = lookup("cerebras", "gpt-oss-120b");
1660        assert_eq!(caps.message_wire_format, "openai");
1661        assert_eq!(caps.native_tool_wire_format, "openai");
1662        assert!(caps.native_tools);
1663    }
1664
1665    #[test]
1666    fn mock_with_claude_model_routes_to_anthropic() {
1667        reset();
1668        let caps = lookup("mock", "claude-sonnet-4-7");
1669        assert!(caps.defer_loading);
1670        assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
1671    }
1672
1673    #[test]
1674    fn mock_with_gpt_model_routes_to_openai() {
1675        reset();
1676        let caps = lookup("mock", "gpt-5.4-preview");
1677        assert!(caps.defer_loading);
1678        assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1679    }
1680
1681    #[test]
1682    fn mock_with_gemini_model_routes_to_gemini() {
1683        reset();
1684        let caps = lookup("mock", "gemini-2.5-flash");
1685        assert_eq!(caps.message_wire_format, "gemini");
1686        assert_eq!(caps.native_tool_wire_format, "openai");
1687        assert!(caps.prefers_xml_scaffolding);
1688    }
1689
1690    #[test]
1691    fn qwen36_ollama_preserves_thinking() {
1692        reset();
1693        let caps = lookup("ollama", "qwen3.6:35b-a3b-coding-nvfp4");
1694        assert!(!caps.native_tools);
1695        assert_eq!(caps.json_schema.as_deref(), Some("format_kw"));
1696        assert!(!caps.thinking_modes.is_empty());
1697        assert!(
1698            caps.preserve_thinking,
1699            "Qwen3.6 should enable preserve_thinking by default for long-horizon loops"
1700        );
1701        assert_eq!(caps.server_parser, "none");
1702        assert!(!caps.honors_chat_template_kwargs);
1703        assert_eq!(caps.recommended_endpoint.as_deref(), Some("/api/chat"));
1704        assert!(caps.text_tool_wire_format_supported);
1705        assert!(caps.prefers_markdown_scaffolding);
1706        assert_eq!(caps.structured_output_mode, "delimited");
1707        assert!(!caps.prefers_xml_tools);
1708        assert_eq!(caps.thinking_block_style, "inline");
1709    }
1710
1711    #[test]
1712    fn qwen35_ollama_does_not_preserve_thinking() {
1713        reset();
1714        let caps = lookup("ollama", "qwen3.5:35b-a3b-coding-nvfp4");
1715        assert!(caps.native_tools);
1716        assert!(!caps.thinking_modes.is_empty());
1717        assert!(
1718            !caps.preserve_thinking,
1719            "Qwen3.5 lacks the preserve_thinking kwarg — rely on the chat template's rolling checkpoint instead"
1720        );
1721        assert_eq!(caps.server_parser, "ollama_qwen3coder");
1722        assert!(!caps.text_tool_wire_format_supported);
1723    }
1724
1725    #[test]
1726    fn qwen36_routed_providers_all_preserve_thinking() {
1727        reset();
1728        for (provider, model) in [
1729            ("openrouter", "qwen/qwen3.6-plus"),
1730            ("together", "Qwen/Qwen3.6-Plus"),
1731            ("huggingface", "Qwen/Qwen3.6-35B-A3B"),
1732            ("fireworks", "accounts/fireworks/models/qwen3p6-plus"),
1733            ("dashscope", "qwen3.6-plus"),
1734            ("local", "Qwen3.6-35B-A3B"),
1735            ("mlx", "unsloth/Qwen3.6-27B-UD-MLX-4bit"),
1736            ("mlx", "Qwen/Qwen3.6-27B"),
1737        ] {
1738            let caps = lookup(provider, model);
1739            assert!(
1740                !caps.thinking_modes.is_empty(),
1741                "{provider}/{model}: thinking"
1742            );
1743            assert!(
1744                caps.preserve_thinking,
1745                "{provider}/{model}: preserve_thinking must be on for Qwen3.6"
1746            );
1747            assert!(caps.native_tools, "{provider}/{model}: native_tools");
1748            assert_ne!(
1749                caps.server_parser, "ollama_qwen3coder",
1750                "{provider}/{model}: only Ollama routes through the qwen3coder response parser"
1751            );
1752        }
1753
1754        let caps = lookup("llamacpp", "unsloth/Qwen3.6-35B-A3B-GGUF");
1755        assert!(!caps.thinking_modes.is_empty());
1756        assert!(caps.preserve_thinking);
1757        assert!(!caps.native_tools);
1758        assert!(caps.text_tool_wire_format_supported);
1759        assert_eq!(caps.server_parser, "none");
1760    }
1761
1762    #[test]
1763    fn qwen_coder_models_do_not_claim_thinking_modes() {
1764        reset();
1765        for (provider, model) in [
1766            ("together", "Qwen/Qwen3-Coder-Next-FP8"),
1767            ("together", "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8"),
1768            ("openrouter", "qwen/qwen3-coder-next"),
1769            ("huggingface", "Qwen/Qwen3-Coder-Next"),
1770        ] {
1771            let caps = lookup(provider, model);
1772            assert!(caps.native_tools, "{provider}/{model}: native_tools");
1773            assert!(
1774                caps.thinking_modes.is_empty(),
1775                "{provider}/{model}: coder models are non-thinking routes"
1776            );
1777            assert!(
1778                !caps.preserve_thinking,
1779                "{provider}/{model}: preserve_thinking must stay off"
1780            );
1781            assert!(
1782                caps.thinking_disable_directive.is_none(),
1783                "{provider}/{model}: no /no_think shim should be needed"
1784            );
1785        }
1786    }
1787
1788    #[test]
1789    fn llamacpp_qwen_keeps_text_tool_wire_format() {
1790        reset();
1791        let caps = lookup("llamacpp", "unsloth/Qwen3.5-Coder-GGUF");
1792        assert_eq!(caps.server_parser, "none");
1793        assert!(caps.honors_chat_template_kwargs);
1794        assert!(!caps.native_tools);
1795        assert!(caps.text_tool_wire_format_supported);
1796        assert_eq!(
1797            caps.recommended_endpoint.as_deref(),
1798            Some("/v1/chat/completions")
1799        );
1800    }
1801
1802    #[test]
1803    fn devstral_local_routes_default_to_text_tools() {
1804        reset();
1805        for provider in ["ollama", "llamacpp"] {
1806            let caps = lookup(provider, "devstral-small-2:24b");
1807            assert!(!caps.native_tools, "{provider}: native tools stay opt-in");
1808            assert!(
1809                caps.text_tool_wire_format_supported,
1810                "{provider}: text tools should remain available"
1811            );
1812        }
1813    }
1814
1815    #[test]
1816    fn openrouter_mistral_routes_use_native_tools() {
1817        reset();
1818        let caps = lookup("openrouter", "mistralai/mistral-small-2603");
1819        assert!(caps.native_tools);
1820        assert!(caps.text_tool_wire_format_supported);
1821        assert_eq!(caps.structured_output.as_deref(), Some("native"));
1822        assert_eq!(caps.structured_output_mode, "native_json");
1823    }
1824
1825    #[test]
1826    fn dashscope_and_llamacpp_resolve_capabilities() {
1827        reset();
1828        // New sibling providers should fall through to `openai` for
1829        // gpt-*  models even without dedicated rules.
1830        let caps = lookup("dashscope", "gpt-5.4-preview");
1831        assert!(caps.defer_loading);
1832        let caps = lookup("llamacpp", "gpt-5.4-preview");
1833        assert!(caps.defer_loading);
1834    }
1835
1836    #[test]
1837    fn unknown_provider_has_no_capabilities() {
1838        reset();
1839        let caps = lookup("my-custom-proxy", "foo-bar-1");
1840        assert!(!caps.native_tools);
1841        assert!(!caps.defer_loading);
1842        assert!(caps.tool_search.is_empty());
1843    }
1844
1845    #[test]
1846    fn enterprise_routes_expose_format_preferences() {
1847        reset();
1848        let bedrock_claude = lookup("bedrock", "anthropic.claude-opus-4-7-v1:0");
1849        assert!(bedrock_claude.prefers_xml_scaffolding);
1850        assert_eq!(bedrock_claude.structured_output_mode, "xml_tagged");
1851        assert!(!bedrock_claude.supports_assistant_prefill);
1852        assert!(bedrock_claude.prefers_xml_tools);
1853
1854        let azure_o = lookup("azure_openai", "o3-prod");
1855        assert!(azure_o.prefers_markdown_scaffolding);
1856        assert_eq!(azure_o.structured_output_mode, "native_json");
1857        assert!(azure_o.prefers_role_developer);
1858        assert_eq!(azure_o.thinking_block_style, "reasoning_summary");
1859    }
1860
1861    #[test]
1862    fn user_override_adds_new_provider() {
1863        reset();
1864        let toml_src = concat!(
1865            "[[provider.my-proxy]]\n",
1866            "model_match = \"*\"\n",
1867            "native_tools = true\n",
1868            "tool_search = [\"hosted\"]\n",
1869            "prefers_xml_scaffolding = true\n",
1870            "structured_output_mode = \"xml_tagged\"\n",
1871            "supports_assistant_prefill = true\n",
1872            "prefers_xml_tools = true\n",
1873            "thinking_block_style = \"thinking_blocks\"\n",
1874        );
1875        set_user_overrides_toml(toml_src).unwrap();
1876        let caps = lookup("my-proxy", "anything");
1877        assert!(caps.native_tools);
1878        assert_eq!(caps.tool_search, vec!["hosted"]);
1879        assert!(caps.prefers_xml_scaffolding);
1880        assert_eq!(caps.structured_output_mode, "xml_tagged");
1881        assert!(caps.supports_assistant_prefill);
1882        assert!(caps.prefers_xml_tools);
1883        assert_eq!(caps.thinking_block_style, "thinking_blocks");
1884        clear_user_overrides();
1885    }
1886
1887    #[test]
1888    fn user_override_takes_precedence_over_builtin() {
1889        reset();
1890        let toml_src = r#"
1891[[provider.anthropic]]
1892model_match = "claude-opus-*"
1893native_tools = true
1894defer_loading = false
1895tool_search = []
1896"#;
1897        set_user_overrides_toml(toml_src).unwrap();
1898        let caps = lookup("anthropic", "claude-opus-4-7");
1899        assert!(caps.native_tools);
1900        assert!(!caps.defer_loading);
1901        assert!(caps.tool_search.is_empty());
1902        clear_user_overrides();
1903    }
1904
1905    #[test]
1906    fn user_override_from_manifest_toml() {
1907        reset();
1908        let manifest = concat!(
1909            "[package]\n",
1910            "name = \"demo\"\n\n",
1911            "[[capabilities.provider.my-proxy]]\n",
1912            "model_match = \"*\"\n",
1913            "native_tools = true\n",
1914            "tool_search = [\"hosted\"]\n",
1915            "prefers_markdown_scaffolding = true\n",
1916            "structured_output_mode = \"native_json\"\n",
1917            "prefers_role_developer = true\n",
1918            "thinking_block_style = \"reasoning_summary\"\n",
1919        );
1920        set_user_overrides_from_manifest_toml(manifest).unwrap();
1921        let caps = lookup("my-proxy", "foo");
1922        assert!(caps.native_tools);
1923        assert_eq!(caps.tool_search, vec!["hosted"]);
1924        assert!(caps.prefers_markdown_scaffolding);
1925        assert_eq!(caps.structured_output_mode, "native_json");
1926        assert!(caps.prefers_role_developer);
1927        assert_eq!(caps.thinking_block_style, "reasoning_summary");
1928        clear_user_overrides();
1929    }
1930
1931    #[test]
1932    fn version_min_requires_parseable_model() {
1933        reset();
1934        let toml_src = r#"
1935[[provider.custom]]
1936model_match = "*"
1937version_min = [5, 4]
1938native_tools = true
1939"#;
1940        set_user_overrides_toml(toml_src).unwrap();
1941        // Unparseable model ID + version_min → rule doesn't match.
1942        let caps = lookup("custom", "mystery-model");
1943        assert!(!caps.native_tools);
1944        clear_user_overrides();
1945    }
1946
1947    #[test]
1948    fn glob_match_substring() {
1949        assert!(glob_match("*gpt*", "openai/gpt-5.4"));
1950        assert!(glob_match("*claude*", "anthropic/claude-opus-4-7"));
1951        assert!(!glob_match("*xyz*", "openai/gpt-5.4"));
1952    }
1953
1954    #[test]
1955    fn openrouter_namespaced_anthropic_model() {
1956        reset();
1957        let caps = lookup("anthropic", "anthropic/claude-opus-4-7");
1958        assert!(caps.defer_loading);
1959    }
1960
1961    #[test]
1962    fn matrix_rows_include_provider_patterns_and_sources() {
1963        reset();
1964        let rows = matrix_rows();
1965        assert!(rows.iter().any(|row| {
1966            row.provider == "openai"
1967                && row.model == "gpt-4o*"
1968                && row.vision
1969                && row.audio
1970                && row.json_schema.as_deref() == Some("native")
1971                && row.source == "builtin"
1972        }));
1973    }
1974}