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;
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");
32
33/// Parsed on-disk capabilities schema. Public so harn-cli can
34/// construct one directly when wiring harn.toml overrides.
35#[derive(Debug, Clone, Deserialize, Default)]
36pub struct CapabilitiesFile {
37    /// Per-provider ordered rule lists. First matching rule wins.
38    #[serde(default)]
39    pub provider: BTreeMap<String, Vec<ProviderRule>>,
40    /// Per-provider defaults applied to every matching row and to
41    /// provider/model pairs that have no model-specific row. This keeps
42    /// transport-shape facts in data without repeating them on every
43    /// generation-specific capability row.
44    #[serde(default)]
45    pub provider_defaults: BTreeMap<String, ProviderDefaults>,
46    /// Sibling → canonical family mapping. Providers with no rule of
47    /// their own fall through to the named family (recursively).
48    #[serde(default)]
49    pub provider_family: BTreeMap<String, String>,
50}
51
52/// Provider-wide default fields merged into matching rules.
53#[derive(Debug, Clone, Deserialize, Default)]
54pub struct ProviderDefaults {
55    /// Message/request/response wire format used by shared helpers.
56    /// Known values are `openai`, `anthropic`, and `ollama`.
57    #[serde(default)]
58    pub message_wire_format: Option<String>,
59    /// Native tool definition wire shape. Known values are `openai`
60    /// and `anthropic`.
61    #[serde(default)]
62    pub native_tool_wire_format: Option<String>,
63    /// Whether image content blocks may reference remote URLs.
64    #[serde(default)]
65    pub image_url_input_supported: Option<bool>,
66    /// File-upload transport used by `std/files.upload`. Known values
67    /// are `anthropic` and `gemini`.
68    #[serde(default)]
69    pub file_upload_wire_format: Option<String>,
70    /// Provider-specific reasoning request shape for OpenAI-compatible
71    /// transports. Known values are `openrouter` and `enabled`.
72    #[serde(default)]
73    pub reasoning_wire_format: Option<String>,
74    #[serde(default)]
75    pub files_api_supported: Option<bool>,
76    #[serde(default)]
77    pub seed_supported: Option<bool>,
78    #[serde(default)]
79    pub top_k_supported: Option<bool>,
80    #[serde(default)]
81    pub frequency_penalty_supported: Option<bool>,
82    #[serde(default)]
83    pub presence_penalty_supported: Option<bool>,
84}
85
86impl ProviderDefaults {
87    fn overlay(&mut self, other: &ProviderDefaults) {
88        if other.message_wire_format.is_some() {
89            self.message_wire_format = other.message_wire_format.clone();
90        }
91        if other.native_tool_wire_format.is_some() {
92            self.native_tool_wire_format = other.native_tool_wire_format.clone();
93        }
94        if other.image_url_input_supported.is_some() {
95            self.image_url_input_supported = other.image_url_input_supported;
96        }
97        if other.file_upload_wire_format.is_some() {
98            self.file_upload_wire_format = other.file_upload_wire_format.clone();
99        }
100        if other.reasoning_wire_format.is_some() {
101            self.reasoning_wire_format = other.reasoning_wire_format.clone();
102        }
103        if other.files_api_supported.is_some() {
104            self.files_api_supported = other.files_api_supported;
105        }
106        if other.seed_supported.is_some() {
107            self.seed_supported = other.seed_supported;
108        }
109        if other.top_k_supported.is_some() {
110            self.top_k_supported = other.top_k_supported;
111        }
112        if other.frequency_penalty_supported.is_some() {
113            self.frequency_penalty_supported = other.frequency_penalty_supported;
114        }
115        if other.presence_penalty_supported.is_some() {
116            self.presence_penalty_supported = other.presence_penalty_supported;
117        }
118    }
119
120    fn fill_missing_from(&mut self, other: &ProviderDefaults) {
121        if self.message_wire_format.is_none() {
122            self.message_wire_format = other.message_wire_format.clone();
123        }
124        if self.native_tool_wire_format.is_none() {
125            self.native_tool_wire_format = other.native_tool_wire_format.clone();
126        }
127        if self.image_url_input_supported.is_none() {
128            self.image_url_input_supported = other.image_url_input_supported;
129        }
130        if self.file_upload_wire_format.is_none() {
131            self.file_upload_wire_format = other.file_upload_wire_format.clone();
132        }
133        if self.reasoning_wire_format.is_none() {
134            self.reasoning_wire_format = other.reasoning_wire_format.clone();
135        }
136        if self.files_api_supported.is_none() {
137            self.files_api_supported = other.files_api_supported;
138        }
139        if self.seed_supported.is_none() {
140            self.seed_supported = other.seed_supported;
141        }
142        if self.top_k_supported.is_none() {
143            self.top_k_supported = other.top_k_supported;
144        }
145        if self.frequency_penalty_supported.is_none() {
146            self.frequency_penalty_supported = other.frequency_penalty_supported;
147        }
148        if self.presence_penalty_supported.is_none() {
149            self.presence_penalty_supported = other.presence_penalty_supported;
150        }
151    }
152
153    fn has_any_field(&self) -> bool {
154        self.message_wire_format.is_some()
155            || self.native_tool_wire_format.is_some()
156            || self.image_url_input_supported.is_some()
157            || self.file_upload_wire_format.is_some()
158            || self.reasoning_wire_format.is_some()
159            || self.files_api_supported.is_some()
160            || self.seed_supported.is_some()
161            || self.top_k_supported.is_some()
162            || self.frequency_penalty_supported.is_some()
163            || self.presence_penalty_supported.is_some()
164    }
165}
166
167/// One row of the capability matrix.
168#[derive(Debug, Clone, Deserialize)]
169pub struct ProviderRule {
170    /// Glob pattern (supports leading / trailing `*` and a single mid-`*`).
171    /// Matched case-insensitively against the model ID.
172    pub model_match: String,
173    /// Optional `[major, minor]` lower bound. When set, the model ID
174    /// must parse via the provider's version extractor AND compare ≥
175    /// this tuple. Rules with an unparseable `version_min` for the
176    /// given model are skipped, not merged.
177    #[serde(default)]
178    pub version_min: Option<Vec<u32>>,
179    #[serde(default)]
180    pub native_tools: Option<bool>,
181    /// Message/request/response wire format used by shared helpers.
182    /// Known values are `openai`, `anthropic`, and `ollama`.
183    #[serde(default)]
184    pub message_wire_format: Option<String>,
185    /// Native tool definition wire shape. Known values are `openai`
186    /// and `anthropic`.
187    #[serde(default)]
188    pub native_tool_wire_format: Option<String>,
189    #[serde(default)]
190    pub defer_loading: Option<bool>,
191    #[serde(default)]
192    pub tool_search: Option<Vec<String>>,
193    #[serde(default)]
194    pub max_tools: Option<u32>,
195    #[serde(default)]
196    pub prompt_caching: Option<bool>,
197    /// Whether this provider/model route accepts image or other visual
198    /// input blocks through Harn's LLM message path.
199    #[serde(default)]
200    pub vision: Option<bool>,
201    /// Whether this provider/model route accepts audio input blocks
202    /// through Harn's LLM message path.
203    #[serde(default, alias = "audio_supported")]
204    pub audio: Option<bool>,
205    /// Whether this provider/model route accepts PDF/document input blocks
206    /// through Harn's LLM message path.
207    #[serde(default, alias = "pdf_supported")]
208    pub pdf: Option<bool>,
209    /// Whether uploaded file references can be reused in message content.
210    #[serde(default)]
211    pub files_api_supported: Option<bool>,
212    /// File-upload transport used by `std/files.upload`. Known values
213    /// are `anthropic` and `gemini`.
214    #[serde(default)]
215    pub file_upload_wire_format: Option<String>,
216    /// Structured-output transport strategy. Known values are:
217    /// `native`, `tool_use`, `format_kw`, and `none`.
218    #[serde(default)]
219    pub structured_output: Option<String>,
220    /// Legacy name retained for project overrides written before
221    /// `structured_output` became the canonical capability.
222    #[serde(default)]
223    pub json_schema: Option<String>,
224    /// Whether prompt sections should prefer XML-style tags such as
225    /// `<task>` / `<examples>` over Markdown headings.
226    #[serde(default)]
227    pub prefers_xml_scaffolding: Option<bool>,
228    /// Whether prompt sections should prefer Markdown headings such as
229    /// `## Task` / `## Examples`.
230    #[serde(default)]
231    pub prefers_markdown_scaffolding: Option<bool>,
232    /// Preferred logical structured-output prompt shape. This is separate
233    /// from the transport-level `structured_output` strategy above.
234    /// Known values are `native_json`, `delimited`, and `xml_tagged`.
235    #[serde(default)]
236    pub structured_output_mode: Option<String>,
237    /// Whether the route accepts an assistant-role prefill message.
238    #[serde(default)]
239    pub supports_assistant_prefill: Option<bool>,
240    /// Whether durable instructions should use OpenAI's `developer` role
241    /// instead of `system`.
242    #[serde(default)]
243    pub prefers_role_developer: Option<bool>,
244    /// Whether text-rendered tool specifications should use XML wrappers
245    /// instead of JSON-schema prose.
246    #[serde(default)]
247    pub prefers_xml_tools: Option<bool>,
248    /// Preferred representation for model thinking/reasoning blocks in
249    /// transcript-like prompt context. Known values are `none`,
250    /// `thinking_blocks`, `reasoning_summary`, and `inline`.
251    #[serde(default)]
252    pub thinking_block_style: Option<String>,
253    /// Supported thinking/reasoning modes for this rule. Values are
254    /// script-facing mode names: `enabled`, `adaptive`, and `effort`.
255    #[serde(default)]
256    pub thinking_modes: Option<Vec<String>>,
257    /// Whether Anthropic interleaved thinking is supported for this
258    /// provider/model route.
259    #[serde(default)]
260    pub interleaved_thinking_supported: Option<bool>,
261    /// Anthropic beta features that should be requested for this route.
262    #[serde(default)]
263    pub anthropic_beta_features: Option<Vec<String>>,
264    /// Legacy override compatibility. New built-in rules should use
265    /// `thinking_modes` so the capability matrix preserves mode detail.
266    #[serde(default)]
267    pub thinking: Option<bool>,
268    /// Whether the model accepts image inputs in chat content.
269    #[serde(default)]
270    pub vision_supported: Option<bool>,
271    /// Whether image content blocks may reference remote URLs.
272    #[serde(default)]
273    pub image_url_input_supported: Option<bool>,
274    /// Carry `<think>...</think>` blocks in assistant history across turns.
275    /// Qwen3.6 exposes this as `chat_template_kwargs.preserve_thinking`;
276    /// Alibaba recommends enabling it for long-horizon agent loops so the
277    /// model doesn't re-derive context it already worked out in prior turns.
278    /// Anthropic's adaptive-thinking signature contract is stricter but plays
279    /// the same role there.
280    #[serde(default)]
281    pub preserve_thinking: Option<bool>,
282    /// Name of any server-side response parser that can transform model
283    /// bytes before Harn sees them. `none` means the provider returns the
284    /// model text/tool channel without an implicit parser.
285    #[serde(default)]
286    pub server_parser: Option<String>,
287    /// Whether provider-specific `chat_template_kwargs` are honored.
288    /// Some OpenAI-compatible servers silently drop unknown kwargs.
289    #[serde(default)]
290    pub honors_chat_template_kwargs: Option<bool>,
291    /// Whether this route requires OpenAI's `max_completion_tokens`
292    /// request field instead of legacy `max_tokens`.
293    #[serde(default)]
294    pub requires_completion_tokens: Option<bool>,
295    /// Whether this route accepts OpenAI's `reasoning_effort` request field.
296    #[serde(default)]
297    pub reasoning_effort_supported: Option<bool>,
298    /// Whether this route accepts `reasoning_effort: "none"` as a true
299    /// reasoning-off setting. Older GPT-5 variants support effort but only
300    /// floor at `minimal`.
301    #[serde(default)]
302    pub reasoning_none_supported: Option<bool>,
303    /// Provider-specific reasoning request shape for OpenAI-compatible
304    /// transports. Known values are `openrouter` and `enabled`.
305    #[serde(default)]
306    pub reasoning_wire_format: Option<String>,
307    #[serde(default)]
308    pub seed_supported: Option<bool>,
309    #[serde(default)]
310    pub top_k_supported: Option<bool>,
311    #[serde(default)]
312    pub frequency_penalty_supported: Option<bool>,
313    #[serde(default)]
314    pub presence_penalty_supported: Option<bool>,
315    /// Preferred endpoint family for this provider/model route. Values
316    /// are descriptive labels consumed by providers, e.g.
317    /// `/api/generate-raw` for Ollama raw prompt bypass.
318    #[serde(default)]
319    pub recommended_endpoint: Option<String>,
320    /// Whether Harn's text-tool protocol (`<tool_call>name({...})`) can
321    /// survive the provider route and return in the visible response body.
322    #[serde(default)]
323    pub text_tool_wire_format_supported: Option<bool>,
324    /// In-prompt directive that disables this model's "thinking" mode when
325    /// the API doesn't expose a first-class field (or exposes it
326    /// inconsistently across templates / quantizations). For Qwen3 family
327    /// chat templates this is `/no_think`. When `thinking: false` is
328    /// requested and this is set, Harn auto-prepends the directive to the
329    /// system message so script authors don't need to know it exists.
330    #[serde(default)]
331    pub thinking_disable_directive: Option<String>,
332}
333
334/// Resolved capabilities for a `(provider, model)` pair. Unset rule
335/// fields resolve to `false` / empty / `None` so callers never have to
336/// unwrap an `Option<bool>` for what are really boolean gates.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct Capabilities {
339    pub native_tools: bool,
340    pub message_wire_format: String,
341    pub native_tool_wire_format: String,
342    pub defer_loading: bool,
343    pub tool_search: Vec<String>,
344    pub max_tools: Option<u32>,
345    pub prompt_caching: bool,
346    pub vision: bool,
347    pub audio: bool,
348    pub pdf: bool,
349    pub files_api_supported: bool,
350    pub file_upload_wire_format: Option<String>,
351    pub structured_output: Option<String>,
352    /// Legacy mirror for CLI display and older callers.
353    pub json_schema: Option<String>,
354    pub prefers_xml_scaffolding: bool,
355    pub prefers_markdown_scaffolding: bool,
356    pub structured_output_mode: String,
357    pub supports_assistant_prefill: bool,
358    pub prefers_role_developer: bool,
359    pub prefers_xml_tools: bool,
360    pub thinking_block_style: String,
361    pub thinking_modes: Vec<String>,
362    pub interleaved_thinking_supported: bool,
363    pub anthropic_beta_features: Vec<String>,
364    pub vision_supported: bool,
365    pub image_url_input_supported: bool,
366    pub preserve_thinking: bool,
367    pub server_parser: String,
368    pub honors_chat_template_kwargs: bool,
369    pub requires_completion_tokens: bool,
370    pub reasoning_effort_supported: bool,
371    pub reasoning_none_supported: bool,
372    pub reasoning_wire_format: Option<String>,
373    pub seed_supported: bool,
374    pub top_k_supported: bool,
375    pub frequency_penalty_supported: bool,
376    pub presence_penalty_supported: bool,
377    pub recommended_endpoint: Option<String>,
378    pub text_tool_wire_format_supported: bool,
379    pub thinking_disable_directive: Option<String>,
380}
381
382impl Default for Capabilities {
383    fn default() -> Self {
384        Self {
385            native_tools: false,
386            message_wire_format: "openai".to_string(),
387            native_tool_wire_format: "openai".to_string(),
388            defer_loading: false,
389            tool_search: Vec::new(),
390            max_tools: None,
391            prompt_caching: false,
392            vision: false,
393            audio: false,
394            pdf: false,
395            files_api_supported: false,
396            file_upload_wire_format: None,
397            structured_output: None,
398            json_schema: None,
399            prefers_xml_scaffolding: false,
400            prefers_markdown_scaffolding: false,
401            structured_output_mode: "none".to_string(),
402            supports_assistant_prefill: false,
403            prefers_role_developer: false,
404            prefers_xml_tools: false,
405            thinking_block_style: "none".to_string(),
406            thinking_modes: Vec::new(),
407            interleaved_thinking_supported: false,
408            anthropic_beta_features: Vec::new(),
409            vision_supported: false,
410            image_url_input_supported: true,
411            preserve_thinking: false,
412            server_parser: "none".to_string(),
413            honors_chat_template_kwargs: false,
414            requires_completion_tokens: false,
415            reasoning_effort_supported: false,
416            reasoning_none_supported: false,
417            reasoning_wire_format: None,
418            seed_supported: true,
419            top_k_supported: true,
420            frequency_penalty_supported: true,
421            presence_penalty_supported: true,
422            recommended_endpoint: None,
423            text_tool_wire_format_supported: true,
424            thinking_disable_directive: None,
425        }
426    }
427}
428
429/// Display-oriented row for `harn check --provider-matrix` and the generated
430/// docs page. Rows are intentionally rule-shaped: `model` is the rule's
431/// `model_match` pattern, because the shipped capability source of truth is a
432/// first-match rule table rather than an exhaustive remote model inventory.
433#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
434pub struct ProviderCapabilityMatrixRow {
435    pub provider: String,
436    pub model: String,
437    pub version_min: Option<Vec<u32>>,
438    pub thinking: Vec<String>,
439    pub vision: bool,
440    pub audio: bool,
441    pub pdf: bool,
442    pub streaming: bool,
443    pub files_api_supported: bool,
444    pub json_schema: Option<String>,
445    pub prefers_xml_scaffolding: bool,
446    pub prefers_markdown_scaffolding: bool,
447    pub structured_output_mode: String,
448    pub supports_assistant_prefill: bool,
449    pub prefers_role_developer: bool,
450    pub prefers_xml_tools: bool,
451    pub thinking_block_style: String,
452    pub tools: bool,
453    pub cache: bool,
454    pub source: String,
455}
456
457thread_local! {
458    /// Per-thread user overrides installed by the CLI at startup. Kept
459    /// thread-local (not process-static) to match the rest of the VM
460    /// state model — the VM is !Send and each VM thread owns its own
461    /// configuration.
462    static USER_OVERRIDES: RefCell<Option<CapabilitiesFile>> = const { RefCell::new(None) };
463}
464
465/// Lazily-parsed built-in rules. The `include_str!` content is a static
466/// constant; parsing it once per process is safe and free of ordering
467/// hazards.
468static BUILTIN: OnceLock<CapabilitiesFile> = OnceLock::new();
469
470fn builtin() -> &'static CapabilitiesFile {
471    BUILTIN.get_or_init(|| {
472        toml::from_str::<CapabilitiesFile>(BUILTIN_TOML)
473            .expect("capabilities.toml must parse at build time")
474    })
475}
476
477/// Install project-level overrides for the current thread. Usually
478/// called once at CLI bootstrap after reading `harn.toml`. Passing
479/// `None` clears any prior override.
480pub fn set_user_overrides(file: Option<CapabilitiesFile>) {
481    USER_OVERRIDES.with(|cell| *cell.borrow_mut() = file);
482}
483
484/// Clear any thread-local user overrides. Used between test runs.
485pub fn clear_user_overrides() {
486    set_user_overrides(None);
487}
488
489/// Parse a TOML string containing the capabilities section's own shape
490/// (i.e. top-level `[[provider.X]]` + optional `[provider_family]`, the
491/// same layout used by the built-in `capabilities.toml`) and install as
492/// the current thread's override.
493pub fn set_user_overrides_toml(src: &str) -> Result<(), String> {
494    let parsed: CapabilitiesFile = toml::from_str(src).map_err(|e| e.to_string())?;
495    set_user_overrides(Some(parsed));
496    Ok(())
497}
498
499/// Extract the `[capabilities]` section from a full `harn.toml` source
500/// and install it as the current thread's override. The schema inside
501/// that section mirrors `CapabilitiesFile` but with every key prefixed
502/// by `capabilities.`:
503///
504/// ```toml
505/// [[capabilities.provider.my-proxy]]
506/// model_match = "*"
507/// native_tools = true
508/// tool_search = ["hosted"]
509/// ```
510pub fn set_user_overrides_from_manifest_toml(src: &str) -> Result<(), String> {
511    #[derive(Deserialize)]
512    struct Manifest {
513        #[serde(default)]
514        capabilities: Option<CapabilitiesFile>,
515    }
516    let parsed: Manifest = toml::from_str(src).map_err(|e| e.to_string())?;
517    set_user_overrides(parsed.capabilities);
518    Ok(())
519}
520
521/// Look up effective capabilities for a `(provider, model)` pair.
522/// Walks the provider_family chain until it finds a rule list that
523/// matches. Within any one provider's rule list, user overrides are
524/// consulted before the built-in rules. The first matching rule wins —
525/// later rules (and later layers in the family chain) are ignored.
526pub fn lookup(provider: &str, model: &str) -> Capabilities {
527    let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
528    lookup_with(provider, model, builtin(), user.as_ref())
529}
530
531/// Return the currently-effective provider capability rule matrix. User
532/// override rows, when installed for the current thread, are emitted before
533/// built-in rows so the display mirrors lookup precedence.
534pub fn matrix_rows() -> Vec<ProviderCapabilityMatrixRow> {
535    let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
536    let mut rows = Vec::new();
537    if let Some(user) = user.as_ref() {
538        push_matrix_rows(&mut rows, user, "project");
539    }
540    push_matrix_rows(&mut rows, builtin(), "builtin");
541    rows
542}
543
544fn push_matrix_rows(
545    rows: &mut Vec<ProviderCapabilityMatrixRow>,
546    file: &CapabilitiesFile,
547    source: &str,
548) {
549    for (provider, rules) in &file.provider {
550        for rule in rules {
551            rows.push(rule_to_matrix_row(provider, rule, source));
552        }
553    }
554}
555
556fn rule_to_matrix_row(
557    provider: &str,
558    rule: &ProviderRule,
559    source: &str,
560) -> ProviderCapabilityMatrixRow {
561    ProviderCapabilityMatrixRow {
562        provider: provider.to_string(),
563        model: rule.model_match.clone(),
564        version_min: rule.version_min.clone(),
565        thinking: rule_thinking_modes(rule),
566        vision: rule_vision(rule),
567        audio: rule.audio.unwrap_or(false),
568        pdf: rule.pdf.unwrap_or(false),
569        streaming: true,
570        files_api_supported: rule.files_api_supported.unwrap_or(false),
571        json_schema: rule_structured_output(rule),
572        prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
573        prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
574        structured_output_mode: rule_structured_output_mode(rule),
575        supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
576        prefers_role_developer: rule
577            .prefers_role_developer
578            .unwrap_or_else(|| rule.requires_completion_tokens.unwrap_or(false)),
579        prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
580        thinking_block_style: rule_thinking_block_style(rule),
581        tools: rule.native_tools.unwrap_or(false),
582        cache: rule.prompt_caching.unwrap_or(false),
583        source: source.to_string(),
584    }
585}
586
587fn rule_thinking_modes(rule: &ProviderRule) -> Vec<String> {
588    rule.thinking_modes.clone().unwrap_or_else(|| {
589        if rule.thinking.unwrap_or(false) {
590            vec!["enabled".to_string()]
591        } else {
592            Vec::new()
593        }
594    })
595}
596
597fn rule_vision(rule: &ProviderRule) -> bool {
598    rule.vision.or(rule.vision_supported).unwrap_or(false)
599}
600
601fn lookup_with(
602    provider: &str,
603    model: &str,
604    builtin: &CapabilitiesFile,
605    user: Option<&CapabilitiesFile>,
606) -> Capabilities {
607    // Special case: mock spoofs either shape. Try anthropic first
608    // (Claude-shape model strings) so `mock` + `claude-opus-4-7`
609    // resolves to the Anthropic capability row — the same behaviour
610    // the hardcoded dispatch gave before this refactor. The native
611    // tool-definition wire shape is pinned to OpenAI so existing
612    // mock-based tests keep observing `t.function.name` regardless of
613    // which family's capability row matched; per-message wire format
614    // still tracks the matched family so Anthropic-specific request
615    // plumbing (beta headers, file-id passthrough) is exercised when
616    // a Claude model is mocked.
617    if provider == "mock" {
618        let anthropic_defaults = merged_provider_defaults(user, builtin, "anthropic");
619        if let Some(mut caps) =
620            try_match_layer(user, builtin, "anthropic", model, &anthropic_defaults)
621        {
622            caps.native_tool_wire_format = "openai".to_string();
623            return caps;
624        }
625        let openai_defaults = merged_provider_defaults(user, builtin, "openai");
626        if let Some(caps) = try_match_layer(user, builtin, "openai", model, &openai_defaults) {
627            return caps;
628        }
629        return Capabilities::default();
630    }
631
632    // Normal chain: walk provider → family(provider) → ... with a
633    // visited-guard to avoid cycles in malformed user overrides.
634    let mut current = provider.to_string();
635    let mut effective_defaults = ProviderDefaults::default();
636    let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
637    while visited.insert(current.clone()) {
638        let layer_defaults = merged_provider_defaults(user, builtin, &current);
639        if effective_defaults.has_any_field() {
640            effective_defaults.fill_missing_from(&layer_defaults);
641        } else {
642            effective_defaults.overlay(&layer_defaults);
643        }
644        if let Some(caps) = try_match_layer(user, builtin, &current, model, &effective_defaults) {
645            return caps;
646        }
647        let next = user
648            .and_then(|f| f.provider_family.get(&current))
649            .or_else(|| builtin.provider_family.get(&current))
650            .cloned();
651        match next {
652            Some(parent) => current = parent,
653            None => break,
654        }
655    }
656    if effective_defaults.has_any_field() {
657        return defaults_to_caps(&effective_defaults);
658    }
659    Capabilities::default()
660}
661
662/// Try the ordered rule list for `layer_provider` (user rules first,
663/// then built-in rules). Returns `Some(caps)` on the first match, else
664/// `None`. `original_provider` is threaded through only for diagnostics.
665fn try_match_layer(
666    user: Option<&CapabilitiesFile>,
667    builtin: &CapabilitiesFile,
668    layer_provider: &str,
669    model: &str,
670    defaults: &ProviderDefaults,
671) -> Option<Capabilities> {
672    if let Some(user) = user {
673        if let Some(rules) = user.provider.get(layer_provider) {
674            for rule in rules {
675                if rule_matches(rule, model) {
676                    return Some(rule_to_caps(rule, defaults));
677                }
678            }
679        }
680    }
681    if let Some(rules) = builtin.provider.get(layer_provider) {
682        for rule in rules {
683            if rule_matches(rule, model) {
684                return Some(rule_to_caps(rule, defaults));
685            }
686        }
687    }
688    None
689}
690
691fn merged_provider_defaults(
692    user: Option<&CapabilitiesFile>,
693    builtin: &CapabilitiesFile,
694    provider: &str,
695) -> ProviderDefaults {
696    let mut defaults = builtin
697        .provider_defaults
698        .get(provider)
699        .cloned()
700        .unwrap_or_default();
701    if let Some(user_defaults) = user.and_then(|file| file.provider_defaults.get(provider)) {
702        defaults.overlay(user_defaults);
703    }
704    defaults
705}
706
707fn defaults_to_caps(defaults: &ProviderDefaults) -> Capabilities {
708    let empty = ProviderRule {
709        model_match: "*".to_string(),
710        version_min: None,
711        native_tools: None,
712        message_wire_format: None,
713        native_tool_wire_format: None,
714        defer_loading: None,
715        tool_search: None,
716        max_tools: None,
717        prompt_caching: None,
718        vision: None,
719        audio: None,
720        pdf: None,
721        files_api_supported: None,
722        file_upload_wire_format: None,
723        structured_output: None,
724        prefers_xml_scaffolding: None,
725        prefers_markdown_scaffolding: None,
726        structured_output_mode: None,
727        supports_assistant_prefill: None,
728        prefers_role_developer: None,
729        prefers_xml_tools: None,
730        thinking_block_style: None,
731        json_schema: None,
732        thinking_modes: None,
733        interleaved_thinking_supported: None,
734        anthropic_beta_features: None,
735        thinking: None,
736        vision_supported: None,
737        image_url_input_supported: None,
738        preserve_thinking: None,
739        server_parser: None,
740        honors_chat_template_kwargs: None,
741        requires_completion_tokens: None,
742        reasoning_effort_supported: None,
743        reasoning_none_supported: None,
744        reasoning_wire_format: None,
745        seed_supported: None,
746        top_k_supported: None,
747        frequency_penalty_supported: None,
748        presence_penalty_supported: None,
749        recommended_endpoint: None,
750        text_tool_wire_format_supported: None,
751        thinking_disable_directive: None,
752    };
753    rule_to_caps(&empty, defaults)
754}
755
756fn rule_to_caps(rule: &ProviderRule, defaults: &ProviderDefaults) -> Capabilities {
757    let thinking_modes = rule_thinking_modes(rule);
758    Capabilities {
759        native_tools: rule.native_tools.unwrap_or(false),
760        message_wire_format: rule
761            .message_wire_format
762            .clone()
763            .or_else(|| defaults.message_wire_format.clone())
764            .unwrap_or_else(|| "openai".to_string()),
765        native_tool_wire_format: rule
766            .native_tool_wire_format
767            .clone()
768            .or_else(|| defaults.native_tool_wire_format.clone())
769            .unwrap_or_else(|| "openai".to_string()),
770        defer_loading: rule.defer_loading.unwrap_or(false),
771        tool_search: rule.tool_search.clone().unwrap_or_default(),
772        max_tools: rule.max_tools,
773        prompt_caching: rule.prompt_caching.unwrap_or(false),
774        vision: rule_vision(rule),
775        audio: rule.audio.unwrap_or(false),
776        pdf: rule.pdf.unwrap_or(false),
777        files_api_supported: rule
778            .files_api_supported
779            .or(defaults.files_api_supported)
780            .unwrap_or(false),
781        file_upload_wire_format: rule
782            .file_upload_wire_format
783            .clone()
784            .or_else(|| defaults.file_upload_wire_format.clone()),
785        structured_output: rule_structured_output(rule),
786        json_schema: rule_structured_output(rule),
787        prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
788        prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
789        structured_output_mode: rule_structured_output_mode(rule),
790        supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
791        prefers_role_developer: rule.prefers_role_developer.unwrap_or(false),
792        prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
793        thinking_block_style: rule_thinking_block_style(rule),
794        thinking_modes,
795        interleaved_thinking_supported: rule.interleaved_thinking_supported.unwrap_or(false),
796        anthropic_beta_features: rule.anthropic_beta_features.clone().unwrap_or_default(),
797        vision_supported: rule.vision_supported.unwrap_or(false),
798        image_url_input_supported: rule
799            .image_url_input_supported
800            .or(defaults.image_url_input_supported)
801            .unwrap_or(true),
802        preserve_thinking: rule.preserve_thinking.unwrap_or(false),
803        server_parser: rule
804            .server_parser
805            .clone()
806            .unwrap_or_else(|| "none".to_string()),
807        honors_chat_template_kwargs: rule.honors_chat_template_kwargs.unwrap_or(false),
808        requires_completion_tokens: rule.requires_completion_tokens.unwrap_or(false),
809        reasoning_effort_supported: rule.reasoning_effort_supported.unwrap_or(false),
810        reasoning_none_supported: rule.reasoning_none_supported.unwrap_or(false),
811        reasoning_wire_format: rule
812            .reasoning_wire_format
813            .clone()
814            .or_else(|| defaults.reasoning_wire_format.clone()),
815        seed_supported: rule
816            .seed_supported
817            .or(defaults.seed_supported)
818            .unwrap_or(true),
819        top_k_supported: rule
820            .top_k_supported
821            .or(defaults.top_k_supported)
822            .unwrap_or(true),
823        frequency_penalty_supported: rule
824            .frequency_penalty_supported
825            .or(defaults.frequency_penalty_supported)
826            .unwrap_or(true),
827        presence_penalty_supported: rule
828            .presence_penalty_supported
829            .or(defaults.presence_penalty_supported)
830            .unwrap_or(true),
831        recommended_endpoint: rule.recommended_endpoint.clone(),
832        text_tool_wire_format_supported: rule.text_tool_wire_format_supported.unwrap_or(true),
833        thinking_disable_directive: rule.thinking_disable_directive.clone(),
834    }
835}
836
837fn rule_structured_output(rule: &ProviderRule) -> Option<String> {
838    rule.structured_output
839        .clone()
840        .or_else(|| rule.json_schema.clone())
841        .filter(|value| value != "none")
842}
843
844fn rule_structured_output_mode(rule: &ProviderRule) -> String {
845    if let Some(mode) = &rule.structured_output_mode {
846        return mode.clone();
847    }
848    match rule_structured_output(rule).as_deref() {
849        Some("native") | Some("format_kw") => "native_json".to_string(),
850        Some("tool_use") => "xml_tagged".to_string(),
851        _ => "none".to_string(),
852    }
853}
854
855fn rule_thinking_block_style(rule: &ProviderRule) -> String {
856    rule.thinking_block_style.clone().unwrap_or_else(|| {
857        if rule.reasoning_effort_supported.unwrap_or(false)
858            || rule.requires_completion_tokens.unwrap_or(false)
859        {
860            "reasoning_summary".to_string()
861        } else {
862            "none".to_string()
863        }
864    })
865}
866
867fn rule_matches(rule: &ProviderRule, model: &str) -> bool {
868    let lower = model.to_lowercase();
869    if !glob_match(&rule.model_match.to_lowercase(), &lower) {
870        return false;
871    }
872    if let Some(version_min) = &rule.version_min {
873        if version_min.len() != 2 {
874            return false;
875        }
876        let want = (version_min[0], version_min[1]);
877        let have = match extract_version(model) {
878            Some(v) => v,
879            // `version_min` was set but the model ID can't be parsed.
880            // Fail closed: skip this rule so more permissive catch-all
881            // rules below can still match.
882            None => return false,
883        };
884        if have < want {
885            return false;
886        }
887    }
888    true
889}
890
891/// Extract `(major, minor)` from a model ID by trying the Anthropic
892/// parser first (for `claude-*` shapes) then the OpenAI parser (`gpt-*`).
893/// Both parsers return `None` for shapes they don't recognise so this
894/// never mis-parses across families.
895fn extract_version(model: &str) -> Option<(u32, u32)> {
896    claude_generation(model).or_else(|| gpt_generation(model))
897}
898
899/// Simple glob matching with `*` wildcards. Mirrors the helper in
900/// `llm_config.rs` — keep them in sync if either ever grows regex or
901/// character-class support.
902fn glob_match(pattern: &str, input: &str) -> bool {
903    if let Some(prefix) = pattern.strip_suffix('*') {
904        if let Some(rest) = prefix.strip_prefix('*') {
905            // `*foo*` — substring match.
906            return input.contains(rest);
907        }
908        return input.starts_with(prefix);
909    }
910    if let Some(suffix) = pattern.strip_prefix('*') {
911        return input.ends_with(suffix);
912    }
913    if pattern.contains('*') {
914        let parts: Vec<&str> = pattern.split('*').collect();
915        if parts.len() == 2 {
916            return input.starts_with(parts[0]) && input.ends_with(parts[1]);
917        }
918        return input == pattern;
919    }
920    input == pattern
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    fn reset() {
928        clear_user_overrides();
929    }
930
931    #[test]
932    fn anthropic_opus_47_gets_full_capabilities() {
933        reset();
934        let caps = lookup("anthropic", "claude-opus-4-7");
935        assert!(caps.native_tools);
936        assert!(caps.defer_loading);
937        assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
938        assert!(caps.prompt_caching);
939        assert_eq!(caps.thinking_modes, vec!["adaptive"]);
940        assert!(caps.vision_supported);
941        assert!(caps.audio);
942        assert!(caps.pdf);
943        assert!(caps.files_api_supported);
944        assert_eq!(caps.max_tools, Some(10000));
945        assert!(caps.prefers_xml_scaffolding);
946        assert!(!caps.prefers_markdown_scaffolding);
947        assert_eq!(caps.structured_output_mode, "xml_tagged");
948        assert!(!caps.supports_assistant_prefill);
949        assert!(!caps.prefers_role_developer);
950        assert!(caps.prefers_xml_tools);
951        assert_eq!(caps.thinking_block_style, "thinking_blocks");
952    }
953
954    #[test]
955    fn anthropic_opus_46_uses_budgeted_thinking() {
956        reset();
957        let caps = lookup("anthropic", "claude-opus-4-6");
958        assert_eq!(caps.thinking_modes, vec!["enabled"]);
959        assert!(caps.interleaved_thinking_supported);
960        assert!(!caps.supports_assistant_prefill);
961    }
962
963    #[test]
964    fn anthropic_opus_45_does_not_support_interleaved_thinking() {
965        reset();
966        let caps = lookup("anthropic", "claude-opus-4-5");
967        assert_eq!(caps.thinking_modes, vec!["enabled"]);
968        assert!(!caps.interleaved_thinking_supported);
969        assert!(caps.supports_assistant_prefill);
970    }
971
972    #[test]
973    fn override_can_supply_anthropic_beta_features() {
974        reset();
975        let toml_src = r#"
976[[provider.anthropic]]
977model_match = "claude-custom-*"
978native_tools = true
979anthropic_beta_features = ["fine-grained-tool-streaming-2025-05-14"]
980"#;
981        set_user_overrides_toml(toml_src).unwrap();
982        let caps = lookup("anthropic", "claude-custom-1");
983        assert_eq!(
984            caps.anthropic_beta_features,
985            vec!["fine-grained-tool-streaming-2025-05-14"]
986        );
987        reset();
988    }
989
990    #[test]
991    fn anthropic_haiku_44_has_no_tool_search() {
992        reset();
993        let caps = lookup("anthropic", "claude-haiku-4-4");
994        // Haiku 4.4 falls through to the `claude-*` catch-all row.
995        assert!(caps.native_tools);
996        assert!(caps.prompt_caching);
997        assert!(!caps.defer_loading);
998        assert!(caps.tool_search.is_empty());
999    }
1000
1001    #[test]
1002    fn anthropic_haiku_45_supports_tool_search() {
1003        reset();
1004        let caps = lookup("anthropic", "claude-haiku-4-5");
1005        assert!(caps.defer_loading);
1006        assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
1007    }
1008
1009    #[test]
1010    fn old_claude_gets_catchall() {
1011        reset();
1012        let caps = lookup("anthropic", "claude-opus-3-5");
1013        assert!(caps.native_tools);
1014        assert!(caps.prompt_caching);
1015        assert!(!caps.defer_loading);
1016        assert!(caps.tool_search.is_empty());
1017    }
1018
1019    #[test]
1020    fn openai_gpt_54_supports_tool_search() {
1021        reset();
1022        let caps = lookup("openai", "gpt-5.4");
1023        assert!(caps.defer_loading);
1024        assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1025        assert_eq!(caps.json_schema.as_deref(), Some("native"));
1026        assert_eq!(caps.thinking_modes, vec!["effort"]);
1027        assert!(caps.reasoning_effort_supported);
1028        assert!(caps.reasoning_none_supported);
1029        assert!(!caps.prefers_xml_scaffolding);
1030        assert!(caps.prefers_markdown_scaffolding);
1031        assert_eq!(caps.structured_output_mode, "native_json");
1032        assert!(!caps.supports_assistant_prefill);
1033        assert!(!caps.prefers_role_developer);
1034        assert!(!caps.prefers_xml_tools);
1035        assert_eq!(caps.thinking_block_style, "reasoning_summary");
1036    }
1037
1038    #[test]
1039    fn openai_gpt_53_has_reasoning_none_without_tool_search() {
1040        reset();
1041        let caps = lookup("openai", "gpt-5.3");
1042        assert!(caps.native_tools);
1043        assert!(!caps.defer_loading);
1044        assert!(caps.vision_supported);
1045        assert!(caps.tool_search.is_empty());
1046        assert_eq!(caps.thinking_modes, vec!["effort"]);
1047        assert!(caps.reasoning_effort_supported);
1048        assert!(caps.reasoning_none_supported);
1049    }
1050
1051    #[test]
1052    fn openai_original_gpt_5_has_reasoning_floor_without_none() {
1053        reset();
1054        let caps = lookup("openai", "gpt-5");
1055        assert!(caps.native_tools);
1056        assert!(!caps.defer_loading);
1057        assert_eq!(caps.thinking_modes, vec!["effort"]);
1058        assert!(caps.reasoning_effort_supported);
1059        assert!(!caps.reasoning_none_supported);
1060    }
1061
1062    #[test]
1063    fn openai_gpt_4o_matrix_fields_include_multimodal_support() {
1064        reset();
1065        let caps = lookup("openai", "gpt-4o");
1066        assert!(caps.native_tools);
1067        assert!(caps.vision);
1068        assert!(caps.audio);
1069        assert!(!caps.pdf);
1070        assert_eq!(caps.json_schema.as_deref(), Some("native"));
1071    }
1072
1073    #[test]
1074    fn openai_reasoning_models_support_effort() {
1075        reset();
1076        let caps = lookup("openai", "o3");
1077        assert_eq!(caps.thinking_modes, vec!["effort"]);
1078        assert!(caps.requires_completion_tokens);
1079        assert!(caps.reasoning_effort_supported);
1080        assert!(caps.prefers_role_developer);
1081        assert_eq!(caps.thinking_block_style, "reasoning_summary");
1082        let prefixed = lookup("openrouter", "openai/o4-mini");
1083        assert!(prefixed.requires_completion_tokens);
1084        assert!(prefixed.reasoning_effort_supported);
1085    }
1086
1087    #[test]
1088    fn vision_capability_gates_known_multimodal_models() {
1089        reset();
1090        assert!(lookup("openai", "gpt-4o").vision_supported);
1091        assert!(lookup("openai", "gpt-5.4-preview").vision_supported);
1092        assert!(lookup("anthropic", "claude-sonnet-4-6").vision_supported);
1093        assert!(lookup("anthropic", "claude-sonnet-4-6").pdf);
1094        assert!(lookup("anthropic", "claude-sonnet-4-6").files_api_supported);
1095        assert!(lookup("openrouter", "google/gemini-2.5-flash").vision_supported);
1096        assert!(lookup("gemini", "gemini-2.5-flash").vision_supported);
1097        assert!(lookup("gemini", "gemini-2.5-flash").audio);
1098        assert!(lookup("gemini", "gemini-2.5-flash").pdf);
1099        assert_eq!(
1100            lookup("gemini", "gemini-2.5-flash").structured_output_mode,
1101            "native_json"
1102        );
1103        assert!(lookup("ollama", "llava:latest").vision_supported);
1104        assert!(lookup("ollama", "gemma4:26b").vision_supported);
1105        assert!(lookup("ollama", "gemma4-128k:latest").vision_supported);
1106        assert!(!lookup("openai", "gpt-3.5-turbo").vision_supported);
1107        assert!(!lookup("ollama", "qwen3.5:35b-a3b-coding-nvfp4").vision_supported);
1108    }
1109
1110    #[test]
1111    fn openrouter_inherits_openai() {
1112        reset();
1113        let caps = lookup("openrouter", "gpt-5.4");
1114        assert!(caps.defer_loading);
1115        assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1116        assert_eq!(caps.reasoning_wire_format.as_deref(), Some("openrouter"));
1117        assert!(!caps.top_k_supported);
1118    }
1119
1120    #[test]
1121    fn bedrock_claude_uses_anthropic_wire_capabilities() {
1122        reset();
1123        let caps = lookup("bedrock", "anthropic.claude-3-5-sonnet-20240620-v1:0");
1124        assert!(caps.native_tools);
1125        assert_eq!(caps.message_wire_format, "anthropic");
1126        assert_eq!(caps.native_tool_wire_format, "anthropic");
1127    }
1128
1129    #[test]
1130    fn groq_inherits_openai_family_only() {
1131        reset();
1132        let caps = lookup("groq", "gpt-5.5-preview");
1133        assert!(caps.defer_loading);
1134    }
1135
1136    #[test]
1137    fn mock_with_claude_model_routes_to_anthropic() {
1138        reset();
1139        let caps = lookup("mock", "claude-sonnet-4-7");
1140        assert!(caps.defer_loading);
1141        assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
1142    }
1143
1144    #[test]
1145    fn mock_with_gpt_model_routes_to_openai() {
1146        reset();
1147        let caps = lookup("mock", "gpt-5.4-preview");
1148        assert!(caps.defer_loading);
1149        assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1150    }
1151
1152    #[test]
1153    fn qwen36_ollama_preserves_thinking() {
1154        reset();
1155        let caps = lookup("ollama", "qwen3.6:35b-a3b-coding-nvfp4");
1156        assert!(!caps.native_tools);
1157        assert_eq!(caps.json_schema.as_deref(), Some("format_kw"));
1158        assert!(!caps.thinking_modes.is_empty());
1159        assert!(
1160            caps.preserve_thinking,
1161            "Qwen3.6 should enable preserve_thinking by default for long-horizon loops"
1162        );
1163        assert_eq!(caps.server_parser, "none");
1164        assert!(!caps.honors_chat_template_kwargs);
1165        assert_eq!(caps.recommended_endpoint.as_deref(), Some("/api/chat"));
1166        assert!(caps.text_tool_wire_format_supported);
1167        assert!(caps.prefers_markdown_scaffolding);
1168        assert_eq!(caps.structured_output_mode, "delimited");
1169        assert!(!caps.prefers_xml_tools);
1170        assert_eq!(caps.thinking_block_style, "inline");
1171    }
1172
1173    #[test]
1174    fn qwen35_ollama_does_not_preserve_thinking() {
1175        reset();
1176        let caps = lookup("ollama", "qwen3.5:35b-a3b-coding-nvfp4");
1177        assert!(caps.native_tools);
1178        assert!(!caps.thinking_modes.is_empty());
1179        assert!(
1180            !caps.preserve_thinking,
1181            "Qwen3.5 lacks the preserve_thinking kwarg — rely on the chat template's rolling checkpoint instead"
1182        );
1183        assert_eq!(caps.server_parser, "ollama_qwen3coder");
1184        assert!(!caps.text_tool_wire_format_supported);
1185    }
1186
1187    #[test]
1188    fn qwen36_routed_providers_all_preserve_thinking() {
1189        reset();
1190        for (provider, model) in [
1191            ("openrouter", "qwen/qwen3.6-plus"),
1192            ("together", "Qwen/Qwen3.6-Plus"),
1193            ("huggingface", "Qwen/Qwen3.6-35B-A3B"),
1194            ("fireworks", "accounts/fireworks/models/qwen3p6-plus"),
1195            ("dashscope", "qwen3.6-plus"),
1196            ("local", "Qwen3.6-35B-A3B"),
1197            ("mlx", "unsloth/Qwen3.6-27B-UD-MLX-4bit"),
1198            ("mlx", "Qwen/Qwen3.6-27B"),
1199        ] {
1200            let caps = lookup(provider, model);
1201            assert!(
1202                !caps.thinking_modes.is_empty(),
1203                "{provider}/{model}: thinking"
1204            );
1205            assert!(
1206                caps.preserve_thinking,
1207                "{provider}/{model}: preserve_thinking must be on for Qwen3.6"
1208            );
1209            assert!(caps.native_tools, "{provider}/{model}: native_tools");
1210            assert_ne!(
1211                caps.server_parser, "ollama_qwen3coder",
1212                "{provider}/{model}: only Ollama routes through the qwen3coder response parser"
1213            );
1214        }
1215
1216        let caps = lookup("llamacpp", "unsloth/Qwen3.6-35B-A3B-GGUF");
1217        assert!(!caps.thinking_modes.is_empty());
1218        assert!(caps.preserve_thinking);
1219        assert!(!caps.native_tools);
1220        assert!(caps.text_tool_wire_format_supported);
1221        assert_eq!(caps.server_parser, "none");
1222    }
1223
1224    #[test]
1225    fn qwen_coder_models_do_not_claim_thinking_modes() {
1226        reset();
1227        for (provider, model) in [
1228            ("together", "Qwen/Qwen3-Coder-Next-FP8"),
1229            ("together", "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8"),
1230            ("openrouter", "qwen/qwen3-coder-next"),
1231            ("huggingface", "Qwen/Qwen3-Coder-Next"),
1232        ] {
1233            let caps = lookup(provider, model);
1234            assert!(caps.native_tools, "{provider}/{model}: native_tools");
1235            assert!(
1236                caps.thinking_modes.is_empty(),
1237                "{provider}/{model}: coder models are non-thinking routes"
1238            );
1239            assert!(
1240                !caps.preserve_thinking,
1241                "{provider}/{model}: preserve_thinking must stay off"
1242            );
1243            assert!(
1244                caps.thinking_disable_directive.is_none(),
1245                "{provider}/{model}: no /no_think shim should be needed"
1246            );
1247        }
1248    }
1249
1250    #[test]
1251    fn llamacpp_qwen_keeps_text_tool_wire_format() {
1252        reset();
1253        let caps = lookup("llamacpp", "unsloth/Qwen3.5-Coder-GGUF");
1254        assert_eq!(caps.server_parser, "none");
1255        assert!(caps.honors_chat_template_kwargs);
1256        assert!(!caps.native_tools);
1257        assert!(caps.text_tool_wire_format_supported);
1258        assert_eq!(
1259            caps.recommended_endpoint.as_deref(),
1260            Some("/v1/chat/completions")
1261        );
1262    }
1263
1264    #[test]
1265    fn devstral_local_routes_default_to_text_tools() {
1266        reset();
1267        for provider in ["ollama", "llamacpp"] {
1268            let caps = lookup(provider, "devstral-small-2:24b");
1269            assert!(!caps.native_tools, "{provider}: native tools stay opt-in");
1270            assert!(
1271                caps.text_tool_wire_format_supported,
1272                "{provider}: text tools should remain available"
1273            );
1274        }
1275    }
1276
1277    #[test]
1278    fn dashscope_and_llamacpp_resolve_capabilities() {
1279        reset();
1280        // New sibling providers should fall through to `openai` for
1281        // gpt-*  models even without dedicated rules.
1282        let caps = lookup("dashscope", "gpt-5.4-preview");
1283        assert!(caps.defer_loading);
1284        let caps = lookup("llamacpp", "gpt-5.4-preview");
1285        assert!(caps.defer_loading);
1286    }
1287
1288    #[test]
1289    fn unknown_provider_has_no_capabilities() {
1290        reset();
1291        let caps = lookup("my-custom-proxy", "foo-bar-1");
1292        assert!(!caps.native_tools);
1293        assert!(!caps.defer_loading);
1294        assert!(caps.tool_search.is_empty());
1295    }
1296
1297    #[test]
1298    fn enterprise_routes_expose_format_preferences() {
1299        reset();
1300        let bedrock_claude = lookup("bedrock", "anthropic.claude-opus-4-7-v1:0");
1301        assert!(bedrock_claude.prefers_xml_scaffolding);
1302        assert_eq!(bedrock_claude.structured_output_mode, "xml_tagged");
1303        assert!(!bedrock_claude.supports_assistant_prefill);
1304        assert!(bedrock_claude.prefers_xml_tools);
1305
1306        let azure_o = lookup("azure_openai", "o3-prod");
1307        assert!(azure_o.prefers_markdown_scaffolding);
1308        assert_eq!(azure_o.structured_output_mode, "native_json");
1309        assert!(azure_o.prefers_role_developer);
1310        assert_eq!(azure_o.thinking_block_style, "reasoning_summary");
1311    }
1312
1313    #[test]
1314    fn user_override_adds_new_provider() {
1315        reset();
1316        let toml_src = concat!(
1317            "[[provider.my-proxy]]\n",
1318            "model_match = \"*\"\n",
1319            "native_tools = true\n",
1320            "tool_search = [\"hosted\"]\n",
1321            "prefers_xml_scaffolding = true\n",
1322            "structured_output_mode = \"xml_tagged\"\n",
1323            "supports_assistant_prefill = true\n",
1324            "prefers_xml_tools = true\n",
1325            "thinking_block_style = \"thinking_blocks\"\n",
1326        );
1327        set_user_overrides_toml(toml_src).unwrap();
1328        let caps = lookup("my-proxy", "anything");
1329        assert!(caps.native_tools);
1330        assert_eq!(caps.tool_search, vec!["hosted"]);
1331        assert!(caps.prefers_xml_scaffolding);
1332        assert_eq!(caps.structured_output_mode, "xml_tagged");
1333        assert!(caps.supports_assistant_prefill);
1334        assert!(caps.prefers_xml_tools);
1335        assert_eq!(caps.thinking_block_style, "thinking_blocks");
1336        clear_user_overrides();
1337    }
1338
1339    #[test]
1340    fn user_override_takes_precedence_over_builtin() {
1341        reset();
1342        let toml_src = r#"
1343[[provider.anthropic]]
1344model_match = "claude-opus-*"
1345native_tools = true
1346defer_loading = false
1347tool_search = []
1348"#;
1349        set_user_overrides_toml(toml_src).unwrap();
1350        let caps = lookup("anthropic", "claude-opus-4-7");
1351        assert!(caps.native_tools);
1352        assert!(!caps.defer_loading);
1353        assert!(caps.tool_search.is_empty());
1354        clear_user_overrides();
1355    }
1356
1357    #[test]
1358    fn user_override_from_manifest_toml() {
1359        reset();
1360        let manifest = concat!(
1361            "[package]\n",
1362            "name = \"demo\"\n\n",
1363            "[[capabilities.provider.my-proxy]]\n",
1364            "model_match = \"*\"\n",
1365            "native_tools = true\n",
1366            "tool_search = [\"hosted\"]\n",
1367            "prefers_markdown_scaffolding = true\n",
1368            "structured_output_mode = \"native_json\"\n",
1369            "prefers_role_developer = true\n",
1370            "thinking_block_style = \"reasoning_summary\"\n",
1371        );
1372        set_user_overrides_from_manifest_toml(manifest).unwrap();
1373        let caps = lookup("my-proxy", "foo");
1374        assert!(caps.native_tools);
1375        assert_eq!(caps.tool_search, vec!["hosted"]);
1376        assert!(caps.prefers_markdown_scaffolding);
1377        assert_eq!(caps.structured_output_mode, "native_json");
1378        assert!(caps.prefers_role_developer);
1379        assert_eq!(caps.thinking_block_style, "reasoning_summary");
1380        clear_user_overrides();
1381    }
1382
1383    #[test]
1384    fn version_min_requires_parseable_model() {
1385        reset();
1386        let toml_src = r#"
1387[[provider.custom]]
1388model_match = "*"
1389version_min = [5, 4]
1390native_tools = true
1391"#;
1392        set_user_overrides_toml(toml_src).unwrap();
1393        // Unparseable model ID + version_min → rule doesn't match.
1394        let caps = lookup("custom", "mystery-model");
1395        assert!(!caps.native_tools);
1396        clear_user_overrides();
1397    }
1398
1399    #[test]
1400    fn glob_match_substring() {
1401        assert!(glob_match("*gpt*", "openai/gpt-5.4"));
1402        assert!(glob_match("*claude*", "anthropic/claude-opus-4-7"));
1403        assert!(!glob_match("*xyz*", "openai/gpt-5.4"));
1404    }
1405
1406    #[test]
1407    fn openrouter_namespaced_anthropic_model() {
1408        reset();
1409        let caps = lookup("anthropic", "anthropic/claude-opus-4-7");
1410        assert!(caps.defer_loading);
1411    }
1412
1413    #[test]
1414    fn matrix_rows_include_provider_patterns_and_sources() {
1415        reset();
1416        let rows = matrix_rows();
1417        assert!(rows.iter().any(|row| {
1418            row.provider == "openai"
1419                && row.model == "gpt-4o*"
1420                && row.vision
1421                && row.audio
1422                && row.json_schema.as_deref() == Some("native")
1423                && row.source == "builtin"
1424        }));
1425    }
1426}