Skip to main content

harn_vm/llm/
capabilities.rs

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