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