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