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