Skip to main content

harn_vm/llm/capabilities/
rule.rs

1//! Provider-rule model and the capability resolution engine.
2//!
3//! Owns the [`ProviderRule`] matrix row plus the machinery that walks the
4//! provider/family rule chain (`resolve_rule_chain`, `absorb_layer_matches`,
5//! `first_matching_rule`) and materializes a matched rule (or provider
6//! defaults) into a [`Capabilities`] value (`lookup_with`, `rule_to_caps`,
7//! `defaults_to_caps`, and the `rule_*` field-derivation helpers).
8
9use std::collections::{BTreeMap, HashSet};
10
11use serde::Deserialize;
12
13use super::model::{
14    fill_opt, Capabilities, CapabilitiesFile, ComputerUseStyle, ProviderDefaults,
15    ScreenshotScaling, WireDialect,
16};
17use crate::llm::providers::anthropic::claude_generation;
18use crate::llm::providers::openai_compat::gpt_generation;
19
20// Model-pattern matching for capability rules. Shared workspace semantics live
21// in `harn-glob`; keep capability and provider matching on that helper instead
22// of mirroring glob behavior locally.
23use harn_glob::match_name as glob_match;
24
25/// One row of the capability matrix.
26#[derive(Debug, Clone, Deserialize)]
27pub struct ProviderRule {
28    /// Glob pattern (supports leading / trailing `*` and a single mid-`*`).
29    /// Matched case-insensitively against the model ID.
30    pub model_match: String,
31    /// Optional `[major, minor]` lower bound. When set, the model ID
32    /// must parse via the provider's version extractor AND compare ≥
33    /// this tuple. Rules with an unparseable `version_min` for the
34    /// given model are skipped, not merged.
35    #[serde(default)]
36    pub version_min: Option<Vec<u32>>,
37    /// Per-rule fall-through. A matching rule with `extends = true`
38    /// contributes ONLY the fields it explicitly sets; resolution then
39    /// continues to later matching rules (user rules before built-in rules,
40    /// then the `provider_family` chain) and ultimately to provider /
41    /// built-in defaults to fill the rest. A matching rule without
42    /// `extends` (or with `extends = false`) terminates resolution exactly
43    /// as before this flag existed. This lets an overlay tweak one field of
44    /// a shipped row without copying the whole row verbatim (which drifts).
45    #[serde(default)]
46    pub extends: bool,
47    #[serde(default)]
48    pub native_tools: Option<bool>,
49    /// Message/request/response wire format used by shared helpers.
50    /// Known values are `openai`, `anthropic`, `gemini`, and `ollama`.
51    #[serde(default)]
52    pub message_wire_format: Option<String>,
53    /// Native tool definition wire shape. Known values are `openai`
54    /// and `anthropic`.
55    #[serde(default)]
56    pub native_tool_wire_format: Option<String>,
57    #[serde(default)]
58    pub defer_loading: Option<bool>,
59    #[serde(default)]
60    pub tool_search: Option<Vec<String>>,
61    /// Whether Harn supports this route through the provider's native
62    /// Responses-style API instead of generic chat completions.
63    #[serde(default)]
64    pub responses_api: Option<bool>,
65    /// Provider-hosted tools Harn can pass through without local execution.
66    #[serde(default)]
67    pub hosted_tools: Option<Vec<String>>,
68    /// Whether provider-hosted remote MCP connectors can be mediated by the
69    /// provider for this route.
70    #[serde(default)]
71    pub remote_mcp: Option<bool>,
72    /// Whether provider-managed previous-response conversation state is
73    /// available.
74    #[serde(default)]
75    pub conversation_state: Option<bool>,
76    /// Whether provider-side truncation/compaction controls are available.
77    #[serde(default)]
78    pub compaction: Option<bool>,
79    /// Whether provider-side background Responses jobs are available.
80    #[serde(default)]
81    pub background_mode: Option<bool>,
82    /// Whether this provider/model route can be submitted through an
83    /// asynchronous provider Batch API for offline, non-interactive work.
84    #[serde(default)]
85    pub batch_api: Option<bool>,
86    /// Provider batch request/result family. Known values are `openai`,
87    /// `anthropic_messages`, `gemini`, `mistral`, `fireworks`, and `xai`.
88    #[serde(default)]
89    pub batch_wire_format: Option<String>,
90    /// How a batch accepts work: `jsonl_file`, `inline_requests`, or
91    /// `jsonl_or_inline`.
92    #[serde(default)]
93    pub batch_input_mode: Option<String>,
94    /// Published percent discount versus synchronous inference for equivalent
95    /// model traffic, when known.
96    #[serde(default)]
97    pub batch_discount_percent: Option<u32>,
98    /// Target or maximum turnaround window in hours, when the provider
99    /// publishes one.
100    #[serde(default)]
101    pub batch_turnaround_hours: Option<u32>,
102    /// Approval policy modes available when provider-hosted tools execute.
103    #[serde(default)]
104    pub tool_approval_policy: Option<String>,
105    #[serde(default)]
106    pub max_tools: Option<u32>,
107    #[serde(default)]
108    pub prompt_caching: Option<bool>,
109    /// Request-side cache breakpoint strategy for routes that require
110    /// `cache_control` to opt into provider prompt caching. Known values are
111    /// `none`, `top_level`, and `last_block`.
112    #[serde(default)]
113    pub cache_breakpoint_style: Option<String>,
114    /// Whether this provider/model route accepts image or other visual
115    /// input blocks through Harn's LLM message path.
116    #[serde(default)]
117    pub vision: Option<bool>,
118    /// Whether this provider/model route accepts audio input blocks
119    /// through Harn's LLM message path.
120    #[serde(default, alias = "audio_supported")]
121    pub audio: Option<bool>,
122    /// Whether this provider/model route accepts PDF/document input blocks
123    /// through Harn's LLM message path.
124    #[serde(default, alias = "pdf_supported")]
125    pub pdf: Option<bool>,
126    /// Whether this provider/model route accepts video input blocks
127    /// through Harn's LLM message path.
128    #[serde(default, alias = "video_supported")]
129    pub video: Option<bool>,
130    /// Whether uploaded file references can be reused in message content.
131    #[serde(default)]
132    pub files_api_supported: Option<bool>,
133    /// File-upload transport used by `std/files.upload`. Known values
134    /// are `anthropic` and `gemini`.
135    #[serde(default)]
136    pub file_upload_wire_format: Option<String>,
137    /// Structured-output transport strategy. Known values are:
138    /// `native`, `tool_use`, `format_kw`, and `none`.
139    #[serde(default)]
140    pub structured_output: Option<String>,
141    /// Legacy name retained for project overrides written before
142    /// `structured_output` became the canonical capability.
143    #[serde(default)]
144    pub json_schema: Option<String>,
145    /// Whether prompt sections should prefer XML-style tags such as
146    /// `<task>` / `<examples>` over Markdown headings.
147    #[serde(default)]
148    pub prefers_xml_scaffolding: Option<bool>,
149    /// Whether this model's tokenizer reserves `<tool_call>` / `</tool_call>`
150    /// as single special tokens (the native Hermes tool-call markers). When
151    /// true, harn remaps those delimiters to a non-special bracket form on the
152    /// wire to avoid degenerate opener repetition; see [`crate::llm::tool_delimiter`].
153    #[serde(default)]
154    pub reserved_tool_call_token: Option<bool>,
155    /// Whether prompt sections should prefer Markdown headings such as
156    /// `## Task` / `## Examples`.
157    #[serde(default)]
158    pub prefers_markdown_scaffolding: Option<bool>,
159    /// Preferred logical structured-output prompt shape. This is separate
160    /// from the transport-level `structured_output` strategy above.
161    /// Known values are `native_json`, `delimited`, and `xml_tagged`.
162    #[serde(default)]
163    pub structured_output_mode: Option<String>,
164    /// Whether the route accepts an assistant-role prefill message.
165    #[serde(default)]
166    pub supports_assistant_prefill: Option<bool>,
167    /// Whether durable instructions should use OpenAI's `developer` role
168    /// instead of `system`.
169    #[serde(default)]
170    pub prefers_role_developer: Option<bool>,
171    /// Whether text-rendered tool specifications should use XML wrappers
172    /// instead of JSON-schema prose.
173    #[serde(default)]
174    pub prefers_xml_tools: Option<bool>,
175    /// Preferred representation for model thinking/reasoning blocks in
176    /// transcript-like prompt context. Known values are `none`,
177    /// `thinking_blocks`, `reasoning_summary`, and `inline`.
178    #[serde(default)]
179    pub thinking_block_style: Option<String>,
180    /// Supported thinking/reasoning modes for this rule. Values are
181    /// script-facing mode names: `enabled`, `adaptive`, and `effort`.
182    #[serde(default)]
183    pub thinking_modes: Option<Vec<String>>,
184    /// Whether Anthropic interleaved thinking is supported for this
185    /// provider/model route.
186    #[serde(default)]
187    pub interleaved_thinking_supported: Option<bool>,
188    /// Anthropic beta features that should be requested for this route.
189    #[serde(default)]
190    pub anthropic_beta_features: Option<Vec<String>>,
191    /// Legacy override compatibility. New built-in rules should use
192    /// `thinking_modes` so the capability matrix preserves mode detail.
193    #[serde(default)]
194    pub thinking: Option<bool>,
195    /// Whether the model accepts image inputs in chat content.
196    #[serde(default)]
197    pub vision_supported: Option<bool>,
198    /// Whether image content blocks may reference remote URLs.
199    #[serde(default)]
200    pub image_url_input_supported: Option<bool>,
201    /// Carry `<think>...</think>` blocks in assistant history across turns.
202    /// Qwen3.6 exposes this as `chat_template_kwargs.preserve_thinking`;
203    /// Alibaba recommends enabling it for long-horizon agent loops so the
204    /// model doesn't re-derive context it already worked out in prior turns.
205    /// Anthropic's adaptive-thinking signature contract is stricter but plays
206    /// the same role there.
207    #[serde(default)]
208    pub preserve_thinking: Option<bool>,
209    /// Name of any server-side response parser that can transform model
210    /// bytes before Harn sees them. `none` means the provider returns the
211    /// model text/tool channel without an implicit parser.
212    #[serde(default)]
213    pub server_parser: Option<String>,
214    /// Whether provider-specific chat-template options are honored. Most
215    /// OpenAI-compatible servers call this `chat_template_kwargs`; Baseten's
216    /// Model APIs spell the same concept `chat_template_args`.
217    #[serde(default)]
218    pub honors_chat_template_kwargs: Option<bool>,
219    /// Request body field for provider-specific chat-template options when it
220    /// differs from the default `chat_template_kwargs`.
221    #[serde(default)]
222    pub chat_template_options_field: Option<String>,
223    /// Whether this route requires OpenAI's `max_completion_tokens`
224    /// request field instead of legacy `max_tokens`.
225    #[serde(default)]
226    pub requires_completion_tokens: Option<bool>,
227    /// Whether this route is served ONLY by the provider's Responses-style
228    /// API and rejects `/v1/chat/completions` (OpenAI `*-codex` models
229    /// return HTTP 404 "Use the v1/responses endpoint instead" on the chat
230    /// endpoint). When set, Harn routes the call through the Responses
231    /// provider even when the caller did not explicitly request
232    /// `api_mode: "responses"`.
233    #[serde(default)]
234    pub chat_completions_unsupported: Option<bool>,
235    /// Whether this route rejects non-streaming chat-completion requests.
236    /// Harn forces streaming for such routes so callers can keep provider-
237    /// neutral `stream` preferences.
238    #[serde(default)]
239    pub requires_streaming: Option<bool>,
240    /// Whether this route accepts Harn's provider-neutral reasoning effort
241    /// control. Providers project this to their native field (for example
242    /// OpenAI `reasoning_effort` or Anthropic `output_config.effort`).
243    #[serde(default)]
244    pub reasoning_effort_supported: Option<bool>,
245    /// Accepted effort values for routes that expose a narrower subset than
246    /// Harn's provider-neutral enum. Empty means "unknown/all".
247    #[serde(default)]
248    pub reasoning_effort_levels: Option<Vec<String>>,
249    /// Whether this route accepts effort "none" as a true reasoning-off
250    /// setting. Older GPT-5 variants support effort but only floor at
251    /// `minimal`.
252    #[serde(default)]
253    pub reasoning_none_supported: Option<bool>,
254    /// Maximum thinking-budget tokens this model accepts for its high/xhigh/max
255    /// reasoning levels, when the provider takes an explicit token budget rather
256    /// than an effort enum. The canonical case is the native Gemini API
257    /// `generationConfig.thinkingConfig.thinkingBudget` field, whose ceiling
258    /// differs by model (Gemini 2.5 Flash caps at 24576, Pro at 32768).
259    /// Declared alongside the model's other wire capabilities instead of a
260    /// hard-coded `model.contains("flash")` branch in the provider.
261    #[serde(default)]
262    pub max_thinking_budget: Option<i64>,
263    /// Whether this route accepts an explicit disabled/off reasoning switch.
264    /// Some routes require reasoning and reject the provider's disabled shape.
265    #[serde(default)]
266    pub reasoning_disable_supported: Option<bool>,
267    /// Whether this model performs *tool calls inside its reasoning channel*,
268    /// so disabling reasoning silently breaks tool calling. The canonical case
269    /// is the OpenAI gpt-oss (Harmony) family: with reasoning disabled it emits
270    /// 0 tool_calls and a tiny billed-noncommittal completion; with reasoning
271    /// enabled (even `low`) it emits clean native tool calls. This is the
272    /// *opposite* of the Qwen3 quirk (Qwen narrates tool intent in the
273    /// reasoning trace and emits zero `tool_calls`, so Qwen needs reasoning
274    /// OFF for tools). When set, `reasoning_policy` refuses to downgrade the
275    /// auto reasoning level to `off` for tool-bearing tasks (agent/code/verify)
276    /// — flooring instead to the lowest supported effort — so no future
277    /// auto-policy default or session pin can re-introduce the
278    /// billed-noncommittal failure at the data layer.
279    #[serde(default)]
280    pub reasoning_required_for_tools: Option<bool>,
281    /// Whether reasoning-only clean stops may be promoted into visible text.
282    /// Disable this for providers whose `reasoning` field is always private
283    /// trace, even when `content` is empty.
284    #[serde(default)]
285    pub reasoning_text_promotable: Option<bool>,
286    /// Provider-specific reasoning request shape for OpenAI-compatible
287    /// transports. Known values are `openrouter`, `enabled`, and `minimax`.
288    #[serde(default)]
289    pub reasoning_wire_format: Option<String>,
290    #[serde(default)]
291    pub seed_supported: Option<bool>,
292    #[serde(default)]
293    pub top_k_supported: Option<bool>,
294    #[serde(default)]
295    pub temperature_supported: Option<bool>,
296    #[serde(default)]
297    pub top_p_supported: Option<bool>,
298    #[serde(default)]
299    pub frequency_penalty_supported: Option<bool>,
300    #[serde(default)]
301    pub presence_penalty_supported: Option<bool>,
302    /// Accepted provider-native `tool_choice` modes. Empty means unrestricted
303    /// or unknown. Use this for routes whose native tools work, but whose API
304    /// rejects forced/specified tool choices.
305    #[serde(default)]
306    pub allowed_tool_choice_modes: Option<Vec<String>>,
307    /// Whether an assistant `tool_calls` message must be followed immediately
308    /// by `role=tool` messages for every emitted `tool_call_id`.
309    #[serde(default)]
310    pub requires_tool_result_adjacency: Option<bool>,
311    /// Whether a single assistant message may contain multiple tool calls.
312    /// Some OpenAI-compatible providers reject replayed history with more than
313    /// one `tool_calls[]` entry even when the calls were parsed from Harn's text
314    /// tool protocol, so the request builder must serialize history as
315    /// one-call assistant turns for those routes.
316    #[serde(default)]
317    pub supports_parallel_tool_calls: Option<bool>,
318    /// Whether the route rejects `response_format` when native `tools` are
319    /// present. Strict OpenAI-compatible servers such as Cerebras accept each
320    /// feature alone but reject the pair together.
321    #[serde(default)]
322    pub tools_exclude_response_format: Option<bool>,
323    /// Preferred endpoint family for this provider/model route. Values
324    /// are descriptive labels consumed by providers, e.g.
325    /// `/api/generate-raw` for Ollama raw prompt bypass.
326    #[serde(default)]
327    pub recommended_endpoint: Option<String>,
328    /// Whether Harn's text-tool protocol (`<tool_call>name({...})`) can
329    /// survive the provider route and return in the visible response body.
330    #[serde(default)]
331    pub text_tool_wire_format_supported: Option<bool>,
332    /// Preferred tool-calling mode for this provider/model route when
333    /// callers do not explicitly choose `tool_format`. This lets the
334    /// capability matrix route around known provider-native regressions
335    /// without making presets branch on model names.
336    #[serde(default)]
337    pub preferred_tool_format: Option<String>,
338    /// Empirical native/text interchangeability status for this route.
339    /// Known values are descriptive, not gates: `interchangeable`,
340    /// `native_unreliable`, `text_unreliable`, `native_only`,
341    /// `text_only`, and `unknown`.
342    #[serde(default)]
343    pub tool_mode_parity: Option<String>,
344    /// Short human-readable note explaining `tool_mode_parity`.
345    #[serde(default)]
346    pub tool_mode_parity_notes: Option<String>,
347    /// In-prompt directive that disables this model's "thinking" mode when
348    /// the API doesn't expose a first-class field (or exposes it
349    /// inconsistently across templates / quantizations). For Qwen3 family
350    /// chat templates this is `/no_think`. When `thinking: false` is
351    /// requested and this is set, Harn auto-prepends the directive to the
352    /// system message so script authors don't need to know it exists.
353    #[serde(default)]
354    pub thinking_disable_directive: Option<String>,
355    /// Per-task auto-policy reasoning-level overrides for this route.
356    /// Keys are task labels (`agent`, `verify`, `chat`, `summarize`,
357    /// `code`); values are reasoning levels (`off`, `minimal`, `low`,
358    /// `medium`, `high`, `xhigh`, `max`). Consulted by `reasoning_policy` only
359    /// when policy resolves to `auto` — explicit policies always win.
360    ///
361    /// Use this to declare known per-model regressions that should
362    /// flip the auto-policy default, instead of hard-coding the model/
363    /// provider pattern in resolver code. The canonical example is the
364    /// Qwen3 tool-call regression — `{ agent = "off" }` disables
365    /// reasoning whenever a script registers tools with that route,
366    /// matching Qwen's own published guidance.
367    #[serde(default)]
368    pub auto_reasoning_overrides: Option<BTreeMap<String, String>>,
369    /// OpenRouter upstream provider names that must be excluded from routing
370    /// for this `(provider, model)` row. Materialized into the request body's
371    /// `provider.ignore` array (see
372    /// [`crate::llm::providers::openai_compat::apply_openrouter_route_denylist`]).
373    /// This is a data-driven route-around for upstreams that serve a route
374    /// incorrectly while still advertising the model — the canonical case is
375    /// OpenRouter's `Ambient` upstream billing reasoning tokens for
376    /// `qwen/qwen3.6-35b-a3b` and then finishing with empty `tool_calls`,
377    /// while Parasail / AtlasCloud / AkashML serve the identical request
378    /// natively. Only consulted for the `openrouter` provider.
379    #[serde(default)]
380    pub provider_route_denylist: Option<Vec<String>>,
381    /// OpenRouter upstream provider names this `(provider, model)` row is
382    /// PINNED to, in preference order. Materialized into the request body's
383    /// `provider.order` array with `allow_fallbacks = false` (see
384    /// [`crate::llm::providers::openai_compat::apply_openrouter_provider_order`]),
385    /// so OpenRouter only ever routes the model to these known-clean upstreams
386    /// and never silently falls back to a sketchier one. This is the
387    /// *allowlist* counterpart to [`Self::provider_route_denylist`]: prefer it
388    /// when the bad upstreams are intermittent / hard to enumerate but the
389    /// clean ones are few and stable. The canonical case is OpenRouter's
390    /// `openai/gpt-oss-*` route, which fans out across ~17 upstreams in a
391    /// sub-provider lottery; some mis-serialize the Harmony tool call even with
392    /// reasoning ON (billed-noncommittal: 0 tool_calls), while Cerebras and
393    /// Groq serve it cleanly. Only consulted for the `openrouter` provider. An
394    /// empty / unset list means "no pin" (free OpenRouter routing). When both a
395    /// pin and a denylist are present the pin wins (a closed allowlist already
396    /// excludes everything not on it). Validated by the footgun gate in
397    /// [`crate::llm::capability_audit`].
398    #[serde(default)]
399    pub openrouter_provider_order: Option<Vec<String>>,
400    /// Serving-quality / precision trust verdict for this `(provider, model)`
401    /// route. A provider can be live and fast yet still serve a model at
402    /// DEGRADED quality (e.g. an undocumented quantization) or reject otherwise
403    /// valid requests, silently contaminating any eval/meter that trusts its
404    /// numbers. This is the data-driven sibling of [`Self::provider_route_denylist`]
405    /// / [`Self::openrouter_provider_order`]: instead of routing *around* a bad
406    /// upstream, it labels the route's measured precision so tooling (the
407    /// meter precision canary) can refuse to trust a `degraded` route and flag a
408    /// `throttled` one. Known values are `trusted` (full-precision verified
409    /// against a reference), `degraded` (proven to serve at reduced quality),
410    /// `throttled` (full-precision but rate-limited to unusable timing), and
411    /// `unverified` (no verdict — treated the same as unset). Unset means
412    /// `unverified`.
413    #[serde(default)]
414    pub serving_precision: Option<String>,
415    /// How the neutral `computer` tool projects onto this route's native
416    /// computer-use surface. `harn-vm` reads this to decide whether to inject
417    /// a provider-native computer tool (and suppress the plain function copy)
418    /// or leave the function-schema tool untouched. Known values are
419    /// `native_anthropic` (Anthropic `computer_20251124`), `native_openai`
420    /// (OpenAI Responses `computer`), `grounded` (element/mark addressing
421    /// resolved locally), and `function` (generic function-schema fallback).
422    /// Unset means the route has no computer-use surface.
423    #[serde(default)]
424    pub computer_use_style: Option<ComputerUseStyle>,
425    /// Screenshot downscaling policy applied before the image reaches the
426    /// model. `xga` fits within 1024x768 preserving aspect (Anthropic);
427    /// `original` is identity (OpenAI). Unset means unset.
428    #[serde(default)]
429    pub screenshot_scaling: Option<ScreenshotScaling>,
430    /// Whether this route requires echoing acknowledged safety checks on the
431    /// computer-use follow-up turn (OpenAI Responses surfaces
432    /// `pending_safety_checks` that must be echoed as
433    /// `acknowledged_safety_checks`). Unset resolves to `false`.
434    #[serde(default)]
435    pub safety_ack_flow: Option<bool>,
436}
437
438impl ProviderRule {
439    /// Fill every capability field that `self` (the accumulated `extends`
440    /// fall-through chain so far) has NOT explicitly set from `other`, a
441    /// later matching rule with lower precedence. "Explicitly set" is the
442    /// serde `Option` raw-deserialization state — never inferred from a
443    /// field's value equaling the default.
444    ///
445    /// The destructure of `other` is deliberately exhaustive (no `..`
446    /// catch-all): adding a new capability field to [`ProviderRule`] fails
447    /// to compile here until the merge handles it.
448    fn fill_missing_from(&mut self, other: &ProviderRule) {
449        let ProviderRule {
450            // Rule-matching metadata, not capability payload: the merged
451            // chain keeps the first (highest-precedence) rule's identity.
452            model_match: _,
453            version_min: _,
454            extends: _,
455            native_tools,
456            message_wire_format,
457            native_tool_wire_format,
458            defer_loading,
459            tool_search,
460            responses_api,
461            hosted_tools,
462            remote_mcp,
463            conversation_state,
464            compaction,
465            background_mode,
466            batch_api,
467            batch_wire_format,
468            batch_input_mode,
469            batch_discount_percent,
470            batch_turnaround_hours,
471            tool_approval_policy,
472            max_tools,
473            prompt_caching,
474            cache_breakpoint_style,
475            vision,
476            audio,
477            pdf,
478            video,
479            files_api_supported,
480            file_upload_wire_format,
481            structured_output,
482            json_schema,
483            prefers_xml_scaffolding,
484            reserved_tool_call_token,
485            prefers_markdown_scaffolding,
486            structured_output_mode,
487            supports_assistant_prefill,
488            prefers_role_developer,
489            prefers_xml_tools,
490            thinking_block_style,
491            thinking_modes,
492            interleaved_thinking_supported,
493            anthropic_beta_features,
494            thinking,
495            vision_supported,
496            image_url_input_supported,
497            preserve_thinking,
498            server_parser,
499            honors_chat_template_kwargs,
500            chat_template_options_field,
501            requires_completion_tokens,
502            chat_completions_unsupported,
503            requires_streaming,
504            reasoning_effort_supported,
505            reasoning_effort_levels,
506            reasoning_none_supported,
507            max_thinking_budget,
508            reasoning_disable_supported,
509            reasoning_required_for_tools,
510            reasoning_text_promotable,
511            reasoning_wire_format,
512            seed_supported,
513            top_k_supported,
514            temperature_supported,
515            top_p_supported,
516            frequency_penalty_supported,
517            presence_penalty_supported,
518            allowed_tool_choice_modes,
519            requires_tool_result_adjacency,
520            supports_parallel_tool_calls,
521            tools_exclude_response_format,
522            recommended_endpoint,
523            text_tool_wire_format_supported,
524            preferred_tool_format,
525            tool_mode_parity,
526            tool_mode_parity_notes,
527            thinking_disable_directive,
528            auto_reasoning_overrides,
529            provider_route_denylist,
530            openrouter_provider_order,
531            serving_precision,
532            computer_use_style,
533            screenshot_scaling,
534            safety_ack_flow,
535        } = other;
536        fill_opt(&mut self.native_tools, native_tools);
537        fill_opt(&mut self.message_wire_format, message_wire_format);
538        fill_opt(&mut self.native_tool_wire_format, native_tool_wire_format);
539        fill_opt(&mut self.defer_loading, defer_loading);
540        fill_opt(&mut self.tool_search, tool_search);
541        fill_opt(&mut self.responses_api, responses_api);
542        fill_opt(&mut self.hosted_tools, hosted_tools);
543        fill_opt(&mut self.remote_mcp, remote_mcp);
544        fill_opt(&mut self.conversation_state, conversation_state);
545        fill_opt(&mut self.compaction, compaction);
546        fill_opt(&mut self.background_mode, background_mode);
547        fill_opt(&mut self.batch_api, batch_api);
548        fill_opt(&mut self.batch_wire_format, batch_wire_format);
549        fill_opt(&mut self.batch_input_mode, batch_input_mode);
550        fill_opt(&mut self.batch_discount_percent, batch_discount_percent);
551        fill_opt(&mut self.batch_turnaround_hours, batch_turnaround_hours);
552        fill_opt(&mut self.tool_approval_policy, tool_approval_policy);
553        fill_opt(&mut self.max_tools, max_tools);
554        fill_opt(&mut self.prompt_caching, prompt_caching);
555        fill_opt(&mut self.cache_breakpoint_style, cache_breakpoint_style);
556        fill_opt(&mut self.audio, audio);
557        fill_opt(&mut self.pdf, pdf);
558        fill_opt(&mut self.video, video);
559        fill_opt(&mut self.files_api_supported, files_api_supported);
560        fill_opt(&mut self.file_upload_wire_format, file_upload_wire_format);
561        fill_opt(&mut self.prefers_xml_scaffolding, prefers_xml_scaffolding);
562        fill_opt(&mut self.reserved_tool_call_token, reserved_tool_call_token);
563        fill_opt(
564            &mut self.prefers_markdown_scaffolding,
565            prefers_markdown_scaffolding,
566        );
567        fill_opt(&mut self.structured_output_mode, structured_output_mode);
568        fill_opt(
569            &mut self.supports_assistant_prefill,
570            supports_assistant_prefill,
571        );
572        fill_opt(&mut self.prefers_role_developer, prefers_role_developer);
573        fill_opt(&mut self.prefers_xml_tools, prefers_xml_tools);
574        fill_opt(&mut self.thinking_block_style, thinking_block_style);
575        fill_opt(
576            &mut self.interleaved_thinking_supported,
577            interleaved_thinking_supported,
578        );
579        fill_opt(&mut self.anthropic_beta_features, anthropic_beta_features);
580        fill_opt(
581            &mut self.image_url_input_supported,
582            image_url_input_supported,
583        );
584        fill_opt(&mut self.preserve_thinking, preserve_thinking);
585        fill_opt(&mut self.server_parser, server_parser);
586        fill_opt(
587            &mut self.honors_chat_template_kwargs,
588            honors_chat_template_kwargs,
589        );
590        fill_opt(
591            &mut self.chat_template_options_field,
592            chat_template_options_field,
593        );
594        fill_opt(
595            &mut self.requires_completion_tokens,
596            requires_completion_tokens,
597        );
598        fill_opt(
599            &mut self.chat_completions_unsupported,
600            chat_completions_unsupported,
601        );
602        fill_opt(&mut self.requires_streaming, requires_streaming);
603        fill_opt(
604            &mut self.reasoning_effort_supported,
605            reasoning_effort_supported,
606        );
607        fill_opt(&mut self.reasoning_effort_levels, reasoning_effort_levels);
608        fill_opt(&mut self.reasoning_none_supported, reasoning_none_supported);
609        fill_opt(&mut self.max_thinking_budget, max_thinking_budget);
610        fill_opt(
611            &mut self.reasoning_disable_supported,
612            reasoning_disable_supported,
613        );
614        fill_opt(
615            &mut self.reasoning_required_for_tools,
616            reasoning_required_for_tools,
617        );
618        fill_opt(
619            &mut self.reasoning_text_promotable,
620            reasoning_text_promotable,
621        );
622        fill_opt(&mut self.reasoning_wire_format, reasoning_wire_format);
623        fill_opt(&mut self.seed_supported, seed_supported);
624        fill_opt(&mut self.top_k_supported, top_k_supported);
625        fill_opt(&mut self.temperature_supported, temperature_supported);
626        fill_opt(&mut self.top_p_supported, top_p_supported);
627        fill_opt(
628            &mut self.frequency_penalty_supported,
629            frequency_penalty_supported,
630        );
631        fill_opt(
632            &mut self.presence_penalty_supported,
633            presence_penalty_supported,
634        );
635        fill_opt(
636            &mut self.allowed_tool_choice_modes,
637            allowed_tool_choice_modes,
638        );
639        fill_opt(
640            &mut self.requires_tool_result_adjacency,
641            requires_tool_result_adjacency,
642        );
643        fill_opt(
644            &mut self.supports_parallel_tool_calls,
645            supports_parallel_tool_calls,
646        );
647        fill_opt(
648            &mut self.tools_exclude_response_format,
649            tools_exclude_response_format,
650        );
651        fill_opt(&mut self.recommended_endpoint, recommended_endpoint);
652        fill_opt(
653            &mut self.text_tool_wire_format_supported,
654            text_tool_wire_format_supported,
655        );
656        fill_opt(&mut self.preferred_tool_format, preferred_tool_format);
657        fill_opt(&mut self.tool_mode_parity, tool_mode_parity);
658        fill_opt(&mut self.tool_mode_parity_notes, tool_mode_parity_notes);
659        fill_opt(
660            &mut self.thinking_disable_directive,
661            thinking_disable_directive,
662        );
663        fill_opt(&mut self.auto_reasoning_overrides, auto_reasoning_overrides);
664        fill_opt(&mut self.provider_route_denylist, provider_route_denylist);
665        fill_opt(
666            &mut self.openrouter_provider_order,
667            openrouter_provider_order,
668        );
669        fill_opt(&mut self.serving_precision, serving_precision);
670        fill_opt(&mut self.computer_use_style, computer_use_style);
671        fill_opt(&mut self.screenshot_scaling, screenshot_scaling);
672        fill_opt(&mut self.safety_ack_flow, safety_ack_flow);
673        // Legacy alias pairs resolve as ONE logical capability
674        // (`rule_structured_output`, `rule_thinking_modes`, `rule_vision`),
675        // so they fill as a unit: when the accumulated chain has explicitly
676        // set either spelling, the later rule's pair must not leak through
677        // the other spelling and override that explicit choice.
678        if self.structured_output.is_none() && self.json_schema.is_none() {
679            self.structured_output.clone_from(structured_output);
680            self.json_schema.clone_from(json_schema);
681        }
682        if self.thinking_modes.is_none() && self.thinking.is_none() {
683            self.thinking_modes.clone_from(thinking_modes);
684            self.thinking.clone_from(thinking);
685        }
686        if self.vision.is_none() && self.vision_supported.is_none() {
687            self.vision.clone_from(vision);
688            self.vision_supported.clone_from(vision_supported);
689        }
690    }
691}
692
693pub(super) struct MatchedCapabilityRule {
694    /// Provider layer of the first (highest-precedence) matched rule.
695    pub(super) provider: String,
696    /// Effective rule: the first match, with fields it left unset filled from
697    /// later matching rules while the chain opted into `extends` fall-through.
698    pub(super) rule: ProviderRule,
699    /// `model_match` patterns of every absorbed rule, in precedence order.
700    /// A single entry unless the first match set `extends = true`.
701    pub(super) matched_patterns: Vec<String>,
702}
703
704/// Accumulates matching rules along the resolution walk (user rules before
705/// built-in rules within a layer, then the `provider_family` chain). The
706/// first matched rule has the highest precedence; later matches only fill
707/// fields the accumulated chain left unset, and only while every absorbed
708/// rule so far opted into `extends` fall-through.
709#[derive(Default)]
710struct RuleResolution {
711    /// Provider layer of the first matched rule.
712    provider: Option<String>,
713    merged: Option<ProviderRule>,
714    /// `model_match` provenance of every absorbed rule, in precedence order.
715    matched_patterns: Vec<String>,
716}
717
718impl RuleResolution {
719    /// Merge `rule` into the accumulator. Returns `true` when the walk must
720    /// terminate: the rule does not opt into `extends` fall-through, which is
721    /// exactly the pre-`extends` first-match-wins behavior.
722    fn absorb(&mut self, layer_provider: &str, rule: &ProviderRule) -> bool {
723        if self.provider.is_none() {
724            self.provider = Some(layer_provider.to_string());
725        }
726        self.matched_patterns.push(rule.model_match.clone());
727        match &mut self.merged {
728            None => self.merged = Some(rule.clone()),
729            Some(merged) => merged.fill_missing_from(rule),
730        }
731        !rule.extends
732    }
733
734    fn into_matched(self) -> Option<MatchedCapabilityRule> {
735        Some(MatchedCapabilityRule {
736            provider: self.provider?,
737            rule: self.merged.expect("merged is set whenever provider is set"),
738            matched_patterns: self.matched_patterns,
739        })
740    }
741}
742
743/// Scan the ordered rule list for `layer_provider` (user rules first, then
744/// built-in rules), absorbing every matching rule into `resolution` until a
745/// terminating (non-`extends`) match. Returns `true` when resolution
746/// terminated within this layer.
747fn absorb_layer_matches(
748    user: Option<&CapabilitiesFile>,
749    builtin: &CapabilitiesFile,
750    layer_provider: &str,
751    model: &str,
752    resolution: &mut RuleResolution,
753) -> bool {
754    for file in user.into_iter().chain(std::iter::once(builtin)) {
755        if let Some(rules) = file.provider.get(layer_provider) {
756            for rule in rules {
757                if rule_matches(rule, model) && resolution.absorb(layer_provider, rule) {
758                    return true;
759                }
760            }
761        }
762    }
763    false
764}
765
766/// Walk provider → family(provider) → … with a visited-guard, absorbing
767/// matching rules into a [`RuleResolution`] and accumulating per-layer
768/// provider defaults (earlier layers win) exactly as far as the walk gets.
769/// Stops at the first non-`extends` match, so a terminating match at layer N
770/// never consults defaults from layers past N — the pre-`extends` behavior.
771/// An unterminated `extends` chain keeps walking so later layers can fill
772/// its gaps.
773fn resolve_rule_chain(
774    user: Option<&CapabilitiesFile>,
775    builtin: &CapabilitiesFile,
776    provider: &str,
777    model: &str,
778) -> (RuleResolution, ProviderDefaults) {
779    let mut resolution = RuleResolution::default();
780    let mut effective_defaults = ProviderDefaults::default();
781    let mut current = provider.to_string();
782    let mut visited = HashSet::new();
783    while visited.insert(current.clone()) {
784        let layer_defaults = merged_provider_defaults(user, builtin, &current);
785        if effective_defaults.has_any_field() {
786            effective_defaults.fill_missing_from(&layer_defaults);
787        } else {
788            effective_defaults.overlay(&layer_defaults);
789        }
790        if absorb_layer_matches(user, builtin, &current, model, &mut resolution) {
791            break;
792        }
793        let next = user
794            .and_then(|file| file.provider_family.get(&current))
795            .or_else(|| builtin.provider_family.get(&current))
796            .cloned();
797        match next {
798            Some(parent) => current = parent,
799            None => break,
800        }
801    }
802    (resolution, effective_defaults)
803}
804
805pub(super) fn first_matching_rule(
806    user: Option<&CapabilitiesFile>,
807    builtin: &CapabilitiesFile,
808    provider: &str,
809    model: &str,
810) -> Option<MatchedCapabilityRule> {
811    resolve_rule_chain(user, builtin, provider, model)
812        .0
813        .into_matched()
814}
815
816pub(super) fn rule_thinking_modes(rule: &ProviderRule) -> Vec<String> {
817    rule.thinking_modes.clone().unwrap_or_else(|| {
818        if rule.thinking.unwrap_or(false) {
819            vec!["enabled".to_string()]
820        } else {
821            Vec::new()
822        }
823    })
824}
825
826pub(super) fn rule_vision(rule: &ProviderRule) -> bool {
827    rule.vision.or(rule.vision_supported).unwrap_or(false)
828}
829
830pub(super) fn lookup_with(
831    provider: &str,
832    model: &str,
833    builtin: &CapabilitiesFile,
834    user: Option<&CapabilitiesFile>,
835) -> Capabilities {
836    // Special case: mock spoofs either shape. Try anthropic first
837    // (Claude-shape model strings) so `mock` + `claude-opus-4-7`
838    // resolves to the Anthropic capability row — the same behaviour
839    // the hardcoded dispatch gave before this refactor. The native
840    // tool-definition wire shape is pinned to OpenAI so existing
841    // mock-based tests keep observing `t.function.name` regardless of
842    // which family's capability row matched; per-message wire format
843    // still tracks the matched family so Anthropic-specific request
844    // plumbing (beta headers, file-id passthrough) is exercised when
845    // a Claude model is mocked.
846    if provider == "mock" {
847        for family in ["anthropic", "openai", "gemini"] {
848            let defaults = merged_provider_defaults(user, builtin, family);
849            let mut resolution = RuleResolution::default();
850            absorb_layer_matches(user, builtin, family, model, &mut resolution);
851            if let Some(rule) = resolution.merged.as_ref() {
852                let mut caps = rule_to_caps(rule, &defaults);
853                if family == "anthropic" {
854                    caps.native_tool_wire_format = "openai".to_string();
855                }
856                return caps;
857            }
858        }
859        return Capabilities::default();
860    }
861
862    // Normal chain: walk provider → family(provider) → ... with a
863    // visited-guard to avoid cycles in malformed user overrides.
864    let (resolution, effective_defaults) = resolve_rule_chain(user, builtin, provider, model);
865    if let Some(rule) = resolution.merged.as_ref() {
866        return rule_to_caps(rule, &effective_defaults);
867    }
868    if effective_defaults.has_any_field() {
869        return defaults_to_caps(&effective_defaults);
870    }
871    Capabilities::default()
872}
873
874fn merged_provider_defaults(
875    user: Option<&CapabilitiesFile>,
876    builtin: &CapabilitiesFile,
877    provider: &str,
878) -> ProviderDefaults {
879    let mut defaults = builtin
880        .provider_defaults
881        .get(provider)
882        .cloned()
883        .unwrap_or_default();
884    if let Some(user_defaults) = user.and_then(|file| file.provider_defaults.get(provider)) {
885        defaults.overlay(user_defaults);
886    }
887    defaults
888}
889
890fn defaults_to_caps(defaults: &ProviderDefaults) -> Capabilities {
891    let empty = ProviderRule {
892        model_match: "*".to_string(),
893        version_min: None,
894        extends: false,
895        native_tools: None,
896        message_wire_format: None,
897        native_tool_wire_format: None,
898        defer_loading: None,
899        tool_search: None,
900        responses_api: None,
901        hosted_tools: None,
902        remote_mcp: None,
903        conversation_state: None,
904        compaction: None,
905        background_mode: None,
906        tool_approval_policy: None,
907        batch_api: None,
908        batch_wire_format: None,
909        batch_input_mode: None,
910        batch_discount_percent: None,
911        batch_turnaround_hours: None,
912        max_tools: None,
913        prompt_caching: None,
914        cache_breakpoint_style: None,
915        vision: None,
916        audio: None,
917        pdf: None,
918        video: None,
919        files_api_supported: None,
920        file_upload_wire_format: None,
921        structured_output: None,
922        prefers_xml_scaffolding: None,
923        reserved_tool_call_token: None,
924        prefers_markdown_scaffolding: None,
925        structured_output_mode: None,
926        supports_assistant_prefill: None,
927        prefers_role_developer: None,
928        prefers_xml_tools: None,
929        thinking_block_style: None,
930        json_schema: None,
931        thinking_modes: None,
932        interleaved_thinking_supported: None,
933        anthropic_beta_features: None,
934        thinking: None,
935        vision_supported: None,
936        image_url_input_supported: None,
937        preserve_thinking: None,
938        server_parser: None,
939        honors_chat_template_kwargs: None,
940        chat_template_options_field: None,
941        requires_completion_tokens: None,
942        chat_completions_unsupported: None,
943        requires_streaming: None,
944        reasoning_effort_supported: None,
945        reasoning_effort_levels: None,
946        reasoning_none_supported: None,
947        max_thinking_budget: None,
948        reasoning_disable_supported: None,
949        reasoning_required_for_tools: None,
950        reasoning_text_promotable: None,
951        reasoning_wire_format: None,
952        seed_supported: None,
953        top_k_supported: None,
954        temperature_supported: None,
955        top_p_supported: None,
956        frequency_penalty_supported: None,
957        presence_penalty_supported: None,
958        allowed_tool_choice_modes: None,
959        requires_tool_result_adjacency: None,
960        supports_parallel_tool_calls: None,
961        tools_exclude_response_format: None,
962        recommended_endpoint: None,
963        text_tool_wire_format_supported: None,
964        preferred_tool_format: None,
965        tool_mode_parity: None,
966        tool_mode_parity_notes: None,
967        thinking_disable_directive: None,
968        auto_reasoning_overrides: None,
969        provider_route_denylist: None,
970        openrouter_provider_order: None,
971        serving_precision: None,
972        computer_use_style: None,
973        screenshot_scaling: None,
974        safety_ack_flow: None,
975    };
976    let mut caps = rule_to_caps(&empty, defaults);
977    caps.preferred_tool_format = None;
978    caps.tool_mode_parity = None;
979    caps
980}
981
982fn rule_to_caps(rule: &ProviderRule, defaults: &ProviderDefaults) -> Capabilities {
983    let thinking_modes = rule_thinking_modes(rule);
984    let thinking_block_style = rule_thinking_block_style(rule);
985    // A route that represents reasoning as inline `<think>` blocks in prompt
986    // context is exactly the one that emits inline `<think>` in its responses,
987    // so derive the response-splitting quirk from the resolved style rather
988    // than adding a second, drift-prone catalog field.
989    let emits_inline_reasoning = thinking_block_style == "inline";
990    Capabilities {
991        native_tools: rule.native_tools.unwrap_or(false),
992        message_wire_format: WireDialect::from_message_wire_format(
993            &rule
994                .message_wire_format
995                .clone()
996                .or_else(|| defaults.message_wire_format.clone())
997                .unwrap_or_else(|| "openai".to_string()),
998        ),
999        native_tool_wire_format: rule
1000            .native_tool_wire_format
1001            .clone()
1002            .or_else(|| defaults.native_tool_wire_format.clone())
1003            .unwrap_or_else(|| "openai".to_string()),
1004        defer_loading: rule.defer_loading.unwrap_or(false),
1005        tool_search: rule.tool_search.clone().unwrap_or_default(),
1006        responses_api: rule.responses_api.unwrap_or(false),
1007        hosted_tools: rule.hosted_tools.clone().unwrap_or_default(),
1008        remote_mcp: rule.remote_mcp.unwrap_or(false),
1009        conversation_state: rule.conversation_state.unwrap_or(false),
1010        compaction: rule.compaction.unwrap_or(false),
1011        background_mode: rule.background_mode.unwrap_or(false),
1012        batch_api: rule.batch_api.or(defaults.batch_api).unwrap_or(false),
1013        batch_wire_format: rule
1014            .batch_wire_format
1015            .clone()
1016            .or_else(|| defaults.batch_wire_format.clone()),
1017        batch_input_mode: rule
1018            .batch_input_mode
1019            .clone()
1020            .or_else(|| defaults.batch_input_mode.clone()),
1021        batch_discount_percent: rule
1022            .batch_discount_percent
1023            .or(defaults.batch_discount_percent),
1024        batch_turnaround_hours: rule
1025            .batch_turnaround_hours
1026            .or(defaults.batch_turnaround_hours),
1027        tool_approval_policy: rule.tool_approval_policy.clone(),
1028        max_tools: rule.max_tools,
1029        prompt_caching: rule.prompt_caching.unwrap_or(false),
1030        cache_breakpoint_style: rule
1031            .cache_breakpoint_style
1032            .clone()
1033            .unwrap_or_else(|| "none".to_string()),
1034        vision: rule_vision(rule),
1035        audio: rule.audio.unwrap_or(false),
1036        pdf: rule.pdf.unwrap_or(false),
1037        video: rule.video.unwrap_or(false),
1038        files_api_supported: rule
1039            .files_api_supported
1040            .or(defaults.files_api_supported)
1041            .unwrap_or(false),
1042        file_upload_wire_format: rule
1043            .file_upload_wire_format
1044            .clone()
1045            .or_else(|| defaults.file_upload_wire_format.clone()),
1046        structured_output: rule_structured_output(rule),
1047        json_schema: rule_structured_output(rule),
1048        prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
1049        reserved_tool_call_token: rule.reserved_tool_call_token.unwrap_or(false),
1050        prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
1051        structured_output_mode: rule_structured_output_mode(rule),
1052        supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
1053        prefers_role_developer: rule.prefers_role_developer.unwrap_or(false),
1054        prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
1055        thinking_block_style,
1056        emits_inline_reasoning,
1057        thinking_modes,
1058        interleaved_thinking_supported: rule.interleaved_thinking_supported.unwrap_or(false),
1059        anthropic_beta_features: rule.anthropic_beta_features.clone().unwrap_or_default(),
1060        vision_supported: rule.vision_supported.unwrap_or(false),
1061        image_url_input_supported: rule
1062            .image_url_input_supported
1063            .or(defaults.image_url_input_supported)
1064            .unwrap_or(true),
1065        preserve_thinking: rule.preserve_thinking.unwrap_or(false),
1066        server_parser: rule
1067            .server_parser
1068            .clone()
1069            .unwrap_or_else(|| "none".to_string()),
1070        honors_chat_template_kwargs: rule.honors_chat_template_kwargs.unwrap_or(false),
1071        chat_template_options_field: rule.chat_template_options_field.clone(),
1072        requires_completion_tokens: rule.requires_completion_tokens.unwrap_or(false),
1073        chat_completions_unsupported: rule.chat_completions_unsupported.unwrap_or(false),
1074        requires_streaming: rule.requires_streaming.unwrap_or(false),
1075        reasoning_effort_supported: rule.reasoning_effort_supported.unwrap_or(false),
1076        reasoning_effort_levels: rule.reasoning_effort_levels.clone().unwrap_or_default(),
1077        reasoning_none_supported: rule.reasoning_none_supported.unwrap_or(false),
1078        max_thinking_budget: rule.max_thinking_budget,
1079        reasoning_disable_supported: rule.reasoning_disable_supported.unwrap_or(true),
1080        reasoning_required_for_tools: rule.reasoning_required_for_tools.unwrap_or(false),
1081        reasoning_text_promotable: rule.reasoning_text_promotable.unwrap_or(true),
1082        reasoning_wire_format: rule
1083            .reasoning_wire_format
1084            .clone()
1085            .or_else(|| defaults.reasoning_wire_format.clone()),
1086        seed_supported: rule
1087            .seed_supported
1088            .or(defaults.seed_supported)
1089            .unwrap_or(true),
1090        top_k_supported: rule
1091            .top_k_supported
1092            .or(defaults.top_k_supported)
1093            .unwrap_or(true),
1094        temperature_supported: rule
1095            .temperature_supported
1096            .or(defaults.temperature_supported)
1097            .unwrap_or(true),
1098        top_p_supported: rule
1099            .top_p_supported
1100            .or(defaults.top_p_supported)
1101            .unwrap_or(true),
1102        frequency_penalty_supported: rule
1103            .frequency_penalty_supported
1104            .or(defaults.frequency_penalty_supported)
1105            .unwrap_or(true),
1106        presence_penalty_supported: rule
1107            .presence_penalty_supported
1108            .or(defaults.presence_penalty_supported)
1109            .unwrap_or(true),
1110        allowed_tool_choice_modes: rule.allowed_tool_choice_modes.clone().unwrap_or_default(),
1111        requires_tool_result_adjacency: rule.requires_tool_result_adjacency.unwrap_or(false),
1112        supports_parallel_tool_calls: rule.supports_parallel_tool_calls.unwrap_or(true),
1113        tools_exclude_response_format: rule.tools_exclude_response_format.unwrap_or(false),
1114        recommended_endpoint: rule.recommended_endpoint.clone(),
1115        text_tool_wire_format_supported: rule.text_tool_wire_format_supported.unwrap_or(true),
1116        preferred_tool_format: Some(rule_preferred_tool_format(rule)),
1117        tool_mode_parity: Some(rule_tool_mode_parity(rule)),
1118        tool_mode_parity_notes: rule.tool_mode_parity_notes.clone(),
1119        thinking_disable_directive: rule.thinking_disable_directive.clone(),
1120        auto_reasoning_overrides: rule.auto_reasoning_overrides.clone().unwrap_or_default(),
1121        provider_route_denylist: rule.provider_route_denylist.clone().unwrap_or_default(),
1122        openrouter_provider_order: rule.openrouter_provider_order.clone().unwrap_or_default(),
1123        serving_precision: rule
1124            .serving_precision
1125            .clone()
1126            .unwrap_or_else(|| "unverified".to_string()),
1127        computer_use_style: rule.computer_use_style,
1128        screenshot_scaling: rule.screenshot_scaling,
1129        safety_ack_flow: rule.safety_ack_flow.unwrap_or(false),
1130    }
1131}
1132
1133pub(super) fn rule_preferred_tool_format(rule: &ProviderRule) -> String {
1134    // This is the `caps.preferred_tool_format` the runtime `lookup` returns for
1135    // a matched capability row. When the row pins a format, honor it (including
1136    // an explicit `text` — the reverse safety valve). Otherwise derive: native
1137    // models get `native`, text-channel models get `json` (fenced-JSON), the
1138    // GLOBAL text-channel default. Heredoc `text` is never auto-derived.
1139    rule.preferred_tool_format.clone().unwrap_or_else(|| {
1140        if rule.native_tools.unwrap_or(false) {
1141            "native".to_string()
1142        } else {
1143            "json".to_string()
1144        }
1145    })
1146}
1147
1148pub(super) fn rule_tool_mode_parity(rule: &ProviderRule) -> String {
1149    rule.tool_mode_parity.clone().unwrap_or_else(|| {
1150        match (
1151            rule.native_tools.unwrap_or(false),
1152            rule.text_tool_wire_format_supported.unwrap_or(true),
1153        ) {
1154            (true, true) => "unknown".to_string(),
1155            (true, false) => "native_only".to_string(),
1156            (false, true) => "text_only".to_string(),
1157            (false, false) => "unsupported".to_string(),
1158        }
1159    })
1160}
1161
1162pub(super) fn rule_structured_output(rule: &ProviderRule) -> Option<String> {
1163    rule.structured_output
1164        .clone()
1165        .or_else(|| rule.json_schema.clone())
1166        .filter(|value| value != "none")
1167}
1168
1169pub(super) fn rule_structured_output_mode(rule: &ProviderRule) -> String {
1170    if let Some(mode) = &rule.structured_output_mode {
1171        return mode.clone();
1172    }
1173    match rule_structured_output(rule).as_deref() {
1174        Some("native") | Some("format_kw") => "native_json".to_string(),
1175        Some("tool_use") => "xml_tagged".to_string(),
1176        _ => "none".to_string(),
1177    }
1178}
1179
1180pub(super) fn rule_thinking_block_style(rule: &ProviderRule) -> String {
1181    rule.thinking_block_style.clone().unwrap_or_else(|| {
1182        if rule.reasoning_effort_supported.unwrap_or(false)
1183            || rule.requires_completion_tokens.unwrap_or(false)
1184        {
1185            "reasoning_summary".to_string()
1186        } else {
1187            "none".to_string()
1188        }
1189    })
1190}
1191
1192pub(crate) fn rule_matches(rule: &ProviderRule, model: &str) -> bool {
1193    let lower = model.to_lowercase();
1194    if !glob_match(&rule.model_match.to_lowercase(), &lower) {
1195        return false;
1196    }
1197    if let Some(version_min) = &rule.version_min {
1198        if version_min.len() != 2 {
1199            return false;
1200        }
1201        let want = (version_min[0], version_min[1]);
1202        let have = match extract_version(model) {
1203            Some(v) => v,
1204            // `version_min` was set but the model ID can't be parsed.
1205            // Fail closed: skip this rule so more permissive catch-all
1206            // rules below can still match.
1207            None => return false,
1208        };
1209        if have < want {
1210            return false;
1211        }
1212    }
1213    true
1214}
1215
1216/// Extract `(major, minor)` from a model ID by trying the Anthropic
1217/// parser first (for `claude-*` shapes) then the OpenAI parser (`gpt-*`).
1218/// Both parsers return `None` for shapes they don't recognise so this
1219/// never mis-parses across families.
1220fn extract_version(model: &str) -> Option<(u32, u32)> {
1221    claude_generation(model).or_else(|| gpt_generation(model))
1222}
1223
1224#[cfg(test)]
1225mod tests {
1226    use super::super::lookup::parse_capabilities_toml;
1227    use super::*;
1228
1229    #[test]
1230    fn glob_match_substring() {
1231        assert!(glob_match("*gpt*", "openai/gpt-5.4"));
1232        assert!(glob_match("*claude*", "anthropic/claude-opus-4-7"));
1233        assert!(!glob_match("*xyz*", "openai/gpt-5.4"));
1234    }
1235
1236    /// Resolve capabilities for a synthetic provider whose rules come entirely
1237    /// from `src`: the parsed file is passed as the builtin base with no user
1238    /// layer, so no shipped rule interferes with the `extends` assertions.
1239    fn extends_caps(src: &str) -> Capabilities {
1240        let file = parse_capabilities_toml(src).expect("test capabilities toml parses");
1241        lookup_with("testprov", "test-model", &file, None)
1242    }
1243
1244    #[test]
1245    fn extends_rule_fills_unset_fields_from_later_matching_rule() {
1246        // Rule 1 opts into `extends` and sets only native_tools; rule 2 (lower
1247        // precedence, same match) supplies the fields the chain left unset.
1248        let caps = extends_caps(
1249            r#"
1250[[provider.testprov]]
1251model_match = "test-*"
1252extends = true
1253native_tools = true
1254
1255[[provider.testprov]]
1256model_match = "test-*"
1257vision = true
1258message_wire_format = "anthropic"
1259"#,
1260        );
1261        assert!(caps.native_tools, "field from the extends rule applies");
1262        assert!(
1263            caps.vision,
1264            "unset field filled from the later matching rule"
1265        );
1266        assert_eq!(caps.message_wire_format, WireDialect::Anthropic);
1267    }
1268
1269    #[test]
1270    fn non_extends_rule_terminates_resolution_unchanged() {
1271        // Without `extends`, the first match wins outright and the later
1272        // rule's vision never applies — the pre-`extends` first-match-wins
1273        // behavior is preserved.
1274        let caps = extends_caps(
1275            r#"
1276[[provider.testprov]]
1277model_match = "test-*"
1278native_tools = true
1279
1280[[provider.testprov]]
1281model_match = "test-*"
1282vision = true
1283"#,
1284        );
1285        assert!(caps.native_tools);
1286        assert!(
1287            !caps.vision,
1288            "a non-extends first match must not absorb later rules"
1289        );
1290    }
1291
1292    #[test]
1293    fn extends_rule_does_not_override_explicitly_set_field() {
1294        // The higher-precedence extends rule's explicit native_tools = true
1295        // wins; the later rule only fills fields the chain left unset, so its
1296        // native_tools = false is ignored while its vision still applies.
1297        let caps = extends_caps(
1298            r#"
1299[[provider.testprov]]
1300model_match = "test-*"
1301extends = true
1302native_tools = true
1303
1304[[provider.testprov]]
1305model_match = "test-*"
1306native_tools = false
1307vision = true
1308"#,
1309        );
1310        assert!(
1311            caps.native_tools,
1312            "the extends rule's explicit value is not overridden by a lower rule"
1313        );
1314        assert!(caps.vision, "still fills the field the chain left unset");
1315    }
1316
1317    #[test]
1318    fn extends_chain_falls_through_to_provider_defaults() {
1319        // An unterminated extends chain (no later matching rule) fills its
1320        // remaining gaps from provider defaults.
1321        let caps = extends_caps(
1322            r#"
1323[provider_defaults.testprov]
1324seed_supported = true
1325
1326[[provider.testprov]]
1327model_match = "test-*"
1328extends = true
1329native_tools = true
1330"#,
1331        );
1332        assert!(caps.native_tools, "field from the extends rule applies");
1333        assert!(
1334            caps.seed_supported,
1335            "unset field filled from provider defaults"
1336        );
1337    }
1338}