Skip to main content

harn_vm/llm/capabilities/
model.rs

1//! Capability DTOs and the wire-dialect model.
2//!
3//! Pure data types: the on-disk [`CapabilitiesFile`] schema, per-provider
4//! [`ProviderDefaults`], the resolved [`Capabilities`] struct callers consume,
5//! and the [`WireDialect`] enum that types a route's message wire format. The
6//! `ProviderRule` matrix row and the resolution engine that turns these DTOs
7//! into a `Capabilities` live in `super::rule`.
8
9use std::collections::BTreeMap;
10
11use serde::Deserialize;
12
13use super::rule::ProviderRule;
14
15/// Parsed on-disk capabilities schema. Public so harn-cli can
16/// construct one directly when wiring harn.toml overrides.
17#[derive(Debug, Clone, Deserialize, Default)]
18pub struct CapabilitiesFile {
19    /// Per-provider ordered rule lists. The first matching rule wins; a
20    /// matching rule with `extends = true` contributes only the fields it
21    /// sets and lets resolution continue to later matching rules (see
22    /// [`ProviderRule::extends`]).
23    #[serde(default)]
24    pub provider: BTreeMap<String, Vec<ProviderRule>>,
25    /// Per-provider defaults applied to every matching row and to
26    /// provider/model pairs that have no model-specific row. This keeps
27    /// transport-shape facts in data without repeating them on every
28    /// generation-specific capability row.
29    #[serde(default)]
30    pub provider_defaults: BTreeMap<String, ProviderDefaults>,
31    /// Sibling → canonical family mapping. Providers with no rule of
32    /// their own fall through to the named family (recursively).
33    #[serde(default)]
34    pub provider_family: BTreeMap<String, String>,
35    /// Per-provider adaptive rate/concurrency governor limits, keyed by
36    /// provider id. Consumed by `crate::llm::rate_governor` when the
37    /// `llm.rate_governor` flag is enabled, so provider limits stay catalog
38    /// data instead of call-site branches.
39    #[serde(default)]
40    pub provider_limits: BTreeMap<String, ProviderLimits>,
41}
42
43/// Adaptive-governor limits for one provider. Every field is optional so a
44/// catalog fragment can pin just the axes it knows; unset axes fall back to the
45/// governor's conservative built-in defaults.
46#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
47pub struct ProviderLimits {
48    /// Ceiling the AIMD concurrency limiter additively climbs toward on
49    /// sustained success.
50    #[serde(default)]
51    pub max_concurrency: Option<u32>,
52    /// Floor the AIMD limiter multiplicatively decreases toward on a throttle
53    /// signal.
54    #[serde(default)]
55    pub min_concurrency: Option<u32>,
56    /// Requests-per-minute token bucket. `None` disables the RPM bucket.
57    #[serde(default)]
58    pub rpm: Option<u32>,
59    /// Tokens-per-minute token bucket, charged by estimated input + output
60    /// tokens. `None` disables the TPM bucket.
61    #[serde(default)]
62    pub tpm: Option<u64>,
63    /// Whether the AIMD adaptive concurrency loop is active. When `false`, the
64    /// concurrency limit is pinned at `max_concurrency`.
65    #[serde(default)]
66    pub adaptive: Option<bool>,
67    /// Circuit-breaker / backoff parameters. Absent means built-in defaults.
68    #[serde(default)]
69    pub backoff: Option<GovernorBackoff>,
70}
71
72/// Exponential-backoff-with-jitter parameters for the governor circuit breaker.
73/// Provider `Retry-After` values always take precedence over the computed
74/// window.
75#[derive(Debug, Clone, Deserialize, PartialEq)]
76pub struct GovernorBackoff {
77    /// First OPEN window, in milliseconds.
78    #[serde(default)]
79    pub base_ms: Option<u64>,
80    /// Ceiling for the OPEN window, in milliseconds.
81    #[serde(default)]
82    pub max_ms: Option<u64>,
83    /// Growth factor applied per consecutive OPEN cycle.
84    #[serde(default)]
85    pub multiplier: Option<f64>,
86    /// Full-jitter toggle.
87    #[serde(default)]
88    pub jitter: Option<bool>,
89}
90
91/// Provider-wide default fields merged into matching rules.
92#[derive(Debug, Clone, Deserialize, Default)]
93pub struct ProviderDefaults {
94    /// Message/request/response wire format used by shared helpers.
95    /// Known values are `openai`, `anthropic`, `gemini`, and `ollama`.
96    #[serde(default)]
97    pub message_wire_format: Option<String>,
98    /// Which synchronous endpoint family the dialect's routes dispatch to.
99    /// See [`LiveEndpointFamily`].
100    #[serde(default)]
101    pub live_endpoint_family: Option<LiveEndpointFamily>,
102    /// Native tool definition wire shape. Known values are `openai`
103    /// and `anthropic`.
104    #[serde(default)]
105    pub native_tool_wire_format: Option<String>,
106    /// Whether image content blocks may reference remote URLs.
107    #[serde(default)]
108    pub image_url_input_supported: Option<bool>,
109    /// File-upload transport used by `std/files.upload`. Known values
110    /// are `anthropic` and `gemini`.
111    #[serde(default)]
112    pub file_upload_wire_format: Option<String>,
113    /// Provider-specific reasoning request shape for OpenAI-compatible
114    /// transports. Known values are `openrouter` and `enabled`.
115    #[serde(default)]
116    pub reasoning_wire_format: Option<String>,
117    #[serde(default)]
118    pub files_api_supported: Option<bool>,
119    #[serde(default)]
120    pub batch_api: Option<bool>,
121    #[serde(default)]
122    pub batch_wire_format: Option<String>,
123    #[serde(default)]
124    pub batch_input_mode: Option<String>,
125    #[serde(default)]
126    pub batch_discount_percent: Option<u32>,
127    #[serde(default)]
128    pub batch_turnaround_hours: Option<u32>,
129    #[serde(default)]
130    pub batch_max_requests: Option<u64>,
131    #[serde(default)]
132    pub batch_max_input_bytes: Option<u64>,
133    #[serde(default)]
134    pub batch_result_retention_days: Option<u32>,
135    #[serde(default)]
136    pub batch_result_ordering: Option<String>,
137    #[serde(default)]
138    pub batch_partial_failure: Option<String>,
139    #[serde(default)]
140    pub batch_cancellation: Option<String>,
141    #[serde(default)]
142    pub batch_security_notes: Option<Vec<String>>,
143    #[serde(default)]
144    pub batch_operational_notes: Option<Vec<String>>,
145    /// Regions where the provider/model batch route is explicitly supported.
146    /// Empty means the capability is not region-scoped.
147    #[serde(default)]
148    pub batch_regions: Option<Vec<String>>,
149    /// Explicit prompt-cache TTL values this provider can honor on request.
150    /// Empty means the route may cache, but Harn has no explicit TTL knob for
151    /// it. Known values today: `5m`, `1h`.
152    #[serde(default)]
153    pub prompt_cache_ttls: Option<Vec<String>>,
154    /// Shortest prefix this provider will actually cache, in tokens. See
155    /// [`crate::llm::capabilities::rule::ProviderRule::prompt_cache_min_prefix_tokens`].
156    #[serde(default)]
157    pub prompt_cache_min_prefix_tokens: Option<u32>,
158    /// Request-side cache marker placement shared by every adapter for this
159    /// provider. Unknown values fail capability loading.
160    #[serde(default)]
161    pub cache_breakpoint_style: Option<CacheBreakpointStyle>,
162    /// How prior assistant reasoning may be projected back onto the provider
163    /// wire. The privacy-preserving default is `strip`.
164    #[serde(default)]
165    pub reasoning_round_trip: Option<ReasoningRoundTripPolicy>,
166    #[serde(default)]
167    pub seed_supported: Option<bool>,
168    #[serde(default)]
169    pub top_k_supported: Option<bool>,
170    #[serde(default)]
171    pub temperature_supported: Option<bool>,
172    #[serde(default)]
173    pub top_p_supported: Option<bool>,
174    #[serde(default)]
175    pub frequency_penalty_supported: Option<bool>,
176    #[serde(default)]
177    pub presence_penalty_supported: Option<bool>,
178    #[serde(default)]
179    pub stop_supported: Option<bool>,
180}
181
182/// Copies `src` into `dst` when `src` is set (last-writer-wins overlay).
183pub(super) fn overlay_opt<T: Clone>(dst: &mut Option<T>, src: &Option<T>) {
184    if src.is_some() {
185        dst.clone_from(src);
186    }
187}
188
189/// Copies `src` into `dst` only when `dst` is still unset (fill-the-gaps).
190pub(super) fn fill_opt<T: Clone>(dst: &mut Option<T>, src: &Option<T>) {
191    if dst.is_none() {
192        dst.clone_from(src);
193    }
194}
195
196/// Visits every `ProviderDefaults` field once, applying `$op` (`overlay_opt`
197/// or `fill_opt`) to each `(dst, src)` pair. The field roster lives here only;
198/// `overlay`/`fill_missing_from` differ solely in the merge rule they pass.
199macro_rules! merge_provider_defaults {
200    ($dst:expr, $src:expr, $op:path) => {{
201        $op(&mut $dst.message_wire_format, &$src.message_wire_format);
202        $op(&mut $dst.live_endpoint_family, &$src.live_endpoint_family);
203        $op(
204            &mut $dst.native_tool_wire_format,
205            &$src.native_tool_wire_format,
206        );
207        $op(
208            &mut $dst.image_url_input_supported,
209            &$src.image_url_input_supported,
210        );
211        $op(
212            &mut $dst.file_upload_wire_format,
213            &$src.file_upload_wire_format,
214        );
215        $op(&mut $dst.reasoning_wire_format, &$src.reasoning_wire_format);
216        $op(&mut $dst.files_api_supported, &$src.files_api_supported);
217        $op(&mut $dst.batch_api, &$src.batch_api);
218        $op(&mut $dst.batch_wire_format, &$src.batch_wire_format);
219        $op(&mut $dst.batch_input_mode, &$src.batch_input_mode);
220        $op(
221            &mut $dst.batch_discount_percent,
222            &$src.batch_discount_percent,
223        );
224        $op(
225            &mut $dst.batch_turnaround_hours,
226            &$src.batch_turnaround_hours,
227        );
228        $op(&mut $dst.batch_max_requests, &$src.batch_max_requests);
229        $op(&mut $dst.batch_max_input_bytes, &$src.batch_max_input_bytes);
230        $op(
231            &mut $dst.batch_result_retention_days,
232            &$src.batch_result_retention_days,
233        );
234        $op(&mut $dst.batch_result_ordering, &$src.batch_result_ordering);
235        $op(&mut $dst.batch_partial_failure, &$src.batch_partial_failure);
236        $op(&mut $dst.batch_cancellation, &$src.batch_cancellation);
237        $op(&mut $dst.batch_security_notes, &$src.batch_security_notes);
238        $op(
239            &mut $dst.batch_operational_notes,
240            &$src.batch_operational_notes,
241        );
242        $op(&mut $dst.batch_regions, &$src.batch_regions);
243        $op(&mut $dst.prompt_cache_ttls, &$src.prompt_cache_ttls);
244        $op(
245            &mut $dst.prompt_cache_min_prefix_tokens,
246            &$src.prompt_cache_min_prefix_tokens,
247        );
248        $op(
249            &mut $dst.cache_breakpoint_style,
250            &$src.cache_breakpoint_style,
251        );
252        $op(&mut $dst.reasoning_round_trip, &$src.reasoning_round_trip);
253        $op(&mut $dst.seed_supported, &$src.seed_supported);
254        $op(&mut $dst.top_k_supported, &$src.top_k_supported);
255        $op(&mut $dst.temperature_supported, &$src.temperature_supported);
256        $op(&mut $dst.top_p_supported, &$src.top_p_supported);
257        $op(
258            &mut $dst.frequency_penalty_supported,
259            &$src.frequency_penalty_supported,
260        );
261        $op(
262            &mut $dst.presence_penalty_supported,
263            &$src.presence_penalty_supported,
264        );
265        $op(&mut $dst.stop_supported, &$src.stop_supported);
266    }};
267}
268
269impl ProviderDefaults {
270    pub(super) fn overlay(&mut self, other: &ProviderDefaults) {
271        merge_provider_defaults!(self, other, overlay_opt);
272    }
273
274    pub(super) fn fill_missing_from(&mut self, other: &ProviderDefaults) {
275        merge_provider_defaults!(self, other, fill_opt);
276    }
277
278    pub(super) fn has_any_field(&self) -> bool {
279        self.message_wire_format.is_some()
280            || self.live_endpoint_family.is_some()
281            || self.native_tool_wire_format.is_some()
282            || self.image_url_input_supported.is_some()
283            || self.file_upload_wire_format.is_some()
284            || self.reasoning_wire_format.is_some()
285            || self.files_api_supported.is_some()
286            || self.batch_api.is_some()
287            || self.batch_wire_format.is_some()
288            || self.batch_input_mode.is_some()
289            || self.batch_discount_percent.is_some()
290            || self.batch_turnaround_hours.is_some()
291            || self.batch_max_requests.is_some()
292            || self.batch_max_input_bytes.is_some()
293            || self.batch_result_retention_days.is_some()
294            || self.batch_result_ordering.is_some()
295            || self.batch_partial_failure.is_some()
296            || self.batch_cancellation.is_some()
297            || self.batch_security_notes.is_some()
298            || self.batch_operational_notes.is_some()
299            || self.batch_regions.is_some()
300            || self.prompt_cache_ttls.is_some()
301            || self.prompt_cache_min_prefix_tokens.is_some()
302            || self.cache_breakpoint_style.is_some()
303            || self.reasoning_round_trip.is_some()
304            || self.seed_supported.is_some()
305            || self.top_k_supported.is_some()
306            || self.temperature_supported.is_some()
307            || self.top_p_supported.is_some()
308            || self.frequency_penalty_supported.is_some()
309            || self.presence_penalty_supported.is_some()
310            || self.stop_supported.is_some()
311    }
312}
313
314/// The message/request/response wire dialect a route speaks.
315///
316/// This is the single typed representation of what used to be encoded two
317/// different, drift-prone ways: the stringly `Capabilities.message_wire_format`
318/// field (compared against `"anthropic"`/`"gemini"`/`"ollama"` literals at a
319/// dozen call sites) and the `(is_anthropic_style, is_ollama)` boolean pair
320/// threaded independently through the transport/response layers. A closed enum
321/// makes an unhandled or mistyped dialect a compile error and removes the
322/// boolean-blindness where two `bool`s could silently disagree.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum WireDialect {
325    /// Anthropic native Messages API (`/v1/messages`). The only dialect that
326    /// surfaces Claude's extended-thinking stream. `message_wire_format =
327    /// "anthropic"`.
328    Anthropic,
329    /// OpenAI-compatible Chat Completions (`/v1/chat/completions`). The default
330    /// for hosted/openai-shape routes. `message_wire_format = "openai"`.
331    OpenAiCompat,
332    /// Ollama native `/api/chat`. `message_wire_format = "ollama"`.
333    Ollama,
334    /// Google Gemini `generateContent`. `message_wire_format = "gemini"`.
335    Gemini,
336}
337
338impl WireDialect {
339    /// Parse the catalog's `message_wire_format` string. Unrecognized values
340    /// (including the explicit `"openai"`) resolve to [`WireDialect::OpenAiCompat`],
341    /// exactly matching the pre-cutover behavior where every
342    /// `== "anthropic"/"gemini"/"ollama"` check fell through to the
343    /// OpenAI-compatible path.
344    pub fn from_message_wire_format(value: &str) -> WireDialect {
345        match value {
346            "anthropic" => WireDialect::Anthropic,
347            "ollama" => WireDialect::Ollama,
348            "gemini" => WireDialect::Gemini,
349            _ => WireDialect::OpenAiCompat,
350        }
351    }
352
353    /// The canonical `message_wire_format` string for display and round-trip.
354    pub fn as_str(self) -> &'static str {
355        match self {
356            WireDialect::Anthropic => "anthropic",
357            WireDialect::OpenAiCompat => "openai",
358            WireDialect::Ollama => "ollama",
359            WireDialect::Gemini => "gemini",
360        }
361    }
362
363    /// Whether this route speaks Anthropic's native Messages shape.
364    pub fn is_anthropic(self) -> bool {
365        matches!(self, WireDialect::Anthropic)
366    }
367
368    /// Whether this route speaks Ollama's native `/api/chat` shape.
369    pub fn is_ollama(self) -> bool {
370        matches!(self, WireDialect::Ollama)
371    }
372
373    /// Whether this route speaks Google Gemini's `generateContent` shape.
374    pub fn is_gemini(self) -> bool {
375        matches!(self, WireDialect::Gemini)
376    }
377}
378
379/// Which synchronous ("live") endpoint family a route dispatches to.
380///
381/// [`WireDialect`] answers "what does the JSON look like"; most dialects have
382/// exactly one live endpoint, so the two questions collapse. Gemini is the
383/// exception: Google serves the same models over two incompatible synchronous
384/// shapes, so a second axis is needed to say *which* one a route uses.
385///
386/// This axis is deliberately independent of `batch_wire_format`. Gemini Batch
387/// only accepts `generateContent`-shaped bodies, so a route can select
388/// [`LiveEndpointFamily::GeminiInteractions`] for its live traffic while its
389/// batch submissions stay `batch_wire_format = "gemini"`.
390///
391/// It is also deliberately independent of the advisory `recommended_endpoint`
392/// string: this is the value the runtime actually switches on, so it is typed
393/// and an unknown value in a capability source fails the load instead of
394/// silently falling back to the legacy transport.
395#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
396#[serde(rename_all = "snake_case")]
397pub enum LiveEndpointFamily {
398    /// Google Gemini `POST /v1beta/models/{model}:generateContent`. The legacy
399    /// path, still the only shape Gemini Batch accepts and the only one Vertex
400    /// delegates to.
401    GeminiGenerateContent,
402    /// Google Gemini `POST /v1beta/interactions` (GA June 2026). Adds
403    /// server-side conversation state (`previous_interaction_id`), observable
404    /// execution steps, streaming, and background execution.
405    GeminiInteractions,
406}
407
408impl LiveEndpointFamily {
409    /// The canonical capability-source string for display and round-trip.
410    pub const fn as_str(self) -> &'static str {
411        match self {
412            Self::GeminiGenerateContent => "gemini_generate_content",
413            Self::GeminiInteractions => "gemini_interactions",
414        }
415    }
416
417    /// Whether this endpoint family keeps conversation state on the provider,
418    /// making the `previous_response_id` / `store` / `background` options
419    /// meaningful on the route.
420    pub const fn is_stateful(self) -> bool {
421        matches!(self, Self::GeminiInteractions)
422    }
423
424    /// Whether this route dispatches over Gemini's Interactions transport.
425    pub const fn is_gemini_interactions(self) -> bool {
426        matches!(self, Self::GeminiInteractions)
427    }
428}
429
430/// How the neutral `computer` tool projects onto a route's native computer-use
431/// surface (the `computer_use_style` capability). A typed enum rather than a raw
432/// string so an unknown value in a capability source is a load-time
433/// deserialize error instead of a silently-disabled computer tool.
434#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
435#[serde(rename_all = "snake_case")]
436pub enum ComputerUseStyle {
437    /// Anthropic `computer_20251124` native tool.
438    NativeAnthropic,
439    /// OpenAI Responses `computer` native tool.
440    NativeOpenai,
441    /// Accessibility / set-of-marks grounding over the universal function tool.
442    Grounded,
443    /// The plain function-schema `computer` tool (the universal default).
444    Function,
445}
446
447/// Screenshot downscaling policy applied before an image reaches the model (the
448/// `screenshot_scaling` capability). Typed for the same reason as
449/// [`ComputerUseStyle`] — an unknown value fails the capability load loudly.
450#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
451#[serde(rename_all = "snake_case")]
452pub enum ScreenshotScaling {
453    /// Fit within Anthropic's XGA (1024x768), preserving aspect ratio.
454    Xga,
455    /// Send the capture at its native resolution (OpenAI et al.).
456    Original,
457}
458
459/// How a route carries a `system`/`developer`-role message that appears at a
460/// non-leading position in the conversation — the operator-instruction channel
461/// (OpenAI developer messages, Anthropic mid-conversation system messages).
462/// Leading system content is always the system prompt; this governs only the
463/// *interleaved* case. Typed so an unknown value in a capability
464/// source fails the load loudly. `None` on [`Capabilities`] derives a safe
465/// default from the wire dialect — see
466/// [`resolve_system_message_placement`](super::resolve_system_message_placement).
467#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
468#[serde(rename_all = "snake_case")]
469pub enum SystemMessagePlacement {
470    /// The wire API accepts a `system`/`developer` message verbatim at any
471    /// position (OpenAI Chat Completions & Responses, Ollama). Pass through.
472    Inline,
473    /// The API accepts an interleaved `system`-role directive but enforces
474    /// placement rules (Anthropic Opus 4.8: one message must follow a `user`
475    /// turn — or an assistant turn ending in `server_tool_use` — and be the
476    /// last message or be followed by an `assistant` turn; never
477    /// `messages[0]`). A valid consecutive section is merged and kept native;
478    /// anything else folds. `developer` collapses to `system` (Anthropic has no
479    /// developer role).
480    NativeDirective,
481    /// The API has no positional system channel (Anthropic pre-4.8, Gemini
482    /// `systemInstruction`, Bedrock Converse `system[]`). A leading run folds
483    /// into the top-level system prompt; an interleaved directive folds into
484    /// the adjacent user turn as a `<system-reminder>` block so its position
485    /// and operator intent survive. Never 400s, never silently repositioned to
486    /// the global system prompt.
487    Fold,
488}
489
490/// Provider field that must carry Harn's private reasoning from an assistant
491/// turn into that provider's next request.
492///
493/// This is deliberately an enum, rather than an arbitrary catalog string:
494/// replaying private reasoning changes the provider-visible transcript, so an
495/// unknown field must fail capability loading rather than silently leak or
496/// discard it. The durable Harn transcript keeps this as `reasoning`; the
497/// OpenAI-compatible request boundary projects it only for the selected mode.
498#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
499#[serde(rename_all = "snake_case")]
500pub enum ReasoningHistoryWireField {
501    /// Moonshot's OpenAI-compatible API requires this on the prior assistant
502    /// message before matching tool results.
503    ReasoningContent,
504}
505
506/// Placement of a provider prompt-cache breakpoint.
507#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
508#[serde(rename_all = "snake_case")]
509pub enum CacheBreakpointStyle {
510    /// The route has no explicit request marker.
511    #[default]
512    None,
513    /// Put `cache_control` on the request object.
514    TopLevel,
515    /// Put `cache_control` on the final message content block.
516    LastBlock,
517}
518
519impl CacheBreakpointStyle {
520    pub const fn as_str(self) -> &'static str {
521        match self {
522            Self::None => "none",
523            Self::TopLevel => "top_level",
524            Self::LastBlock => "last_block",
525        }
526    }
527}
528
529/// Provider-visible replay policy for private assistant reasoning.
530///
531/// Reasoning is stripped unless a capability row explicitly opts into the
532/// provider's cryptographically bound or same-field continuation contract.
533#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
534#[serde(rename_all = "snake_case")]
535pub enum ReasoningRoundTripPolicy {
536    /// Never send private reasoning back to the provider.
537    #[default]
538    Strip,
539    /// Replay only provider-signed opaque reasoning blocks.
540    EchoSigned,
541    /// Replay Harn's canonical reasoning text under the typed provider field.
542    EchoSameKey,
543}
544
545impl ReasoningRoundTripPolicy {
546    pub const fn as_str(self) -> &'static str {
547        match self {
548            Self::Strip => "strip",
549            Self::EchoSigned => "echo_signed",
550            Self::EchoSameKey => "echo_same_key",
551        }
552    }
553}
554
555impl ReasoningHistoryWireField {
556    pub const fn as_str(self) -> &'static str {
557        match self {
558            Self::ReasoningContent => "reasoning_content",
559        }
560    }
561}
562
563/// Where a route's `tool_mode_parity` verdict came from.
564///
565/// Both variants are declarations about a route, not measurements of one. A
566/// forced-format sweep is a different kind of evidence and has its own slot,
567/// `tool_support.empirical_parity`, carrying pass rates and a sample size.
568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569pub enum ToolModeParitySource {
570    /// A capability row states the verdict outright.
571    Declared,
572    /// No row stated one, so it was computed from `native_tools` and
573    /// `text_tool_wire_format_supported`. A consumer that needs evidence
574    /// should treat this as "not established" rather than as a finding.
575    Derived,
576}
577
578impl ToolModeParitySource {
579    pub fn as_str(self) -> &'static str {
580        match self {
581            Self::Declared => "declared",
582            Self::Derived => "derived",
583        }
584    }
585}
586
587/// Resolved capabilities for a `(provider, model)` pair. Unset rule
588/// fields resolve to `false` / empty / `None` so callers never have to
589/// unwrap an `Option<bool>` for what are really boolean gates.
590#[derive(Debug, Clone, PartialEq, Eq)]
591pub struct Capabilities {
592    pub native_tools: bool,
593    pub message_wire_format: WireDialect,
594    pub native_tool_wire_format: String,
595    pub defer_loading: bool,
596    pub tool_search: Vec<String>,
597    pub responses_api: bool,
598    pub hosted_tools: Vec<String>,
599    pub remote_mcp: bool,
600    pub conversation_state: bool,
601    pub compaction: bool,
602    pub background_mode: bool,
603    pub batch_api: bool,
604    pub batch_wire_format: Option<String>,
605    pub batch_input_mode: Option<String>,
606    pub batch_discount_percent: Option<u32>,
607    pub batch_turnaround_hours: Option<u32>,
608    pub batch_max_requests: Option<u64>,
609    pub batch_max_input_bytes: Option<u64>,
610    pub batch_result_retention_days: Option<u32>,
611    pub batch_result_ordering: Option<String>,
612    pub batch_partial_failure: Option<String>,
613    pub batch_cancellation: Option<String>,
614    pub batch_security_notes: Vec<String>,
615    pub batch_operational_notes: Vec<String>,
616    pub batch_regions: Vec<String>,
617    pub tool_approval_policy: Option<String>,
618    pub max_tools: Option<u32>,
619    pub prompt_caching: bool,
620    pub prompt_cache_ttls: Vec<String>,
621    /// Shortest prefix this route will actually cache, in tokens. `None` means
622    /// the route has no model-specific floor and the wire-dialect default in
623    /// [`crate::llm::cache_conformance::CacheControlProfile`] applies.
624    pub prompt_cache_min_prefix_tokens: Option<u32>,
625    pub cache_breakpoint_style: CacheBreakpointStyle,
626    pub vision: bool,
627    pub audio: bool,
628    pub pdf: bool,
629    pub video: bool,
630    pub files_api_supported: bool,
631    pub file_upload_wire_format: Option<String>,
632    pub structured_output: Option<String>,
633    /// Legacy mirror for CLI display and older callers.
634    pub json_schema: Option<String>,
635    pub prefers_xml_scaffolding: bool,
636    /// See [`ProviderRule::reserved_tool_call_token`].
637    pub reserved_tool_call_token: bool,
638    pub prefers_markdown_scaffolding: bool,
639    pub structured_output_mode: String,
640    pub supports_assistant_prefill: bool,
641    pub prefers_role_developer: bool,
642    pub prefers_xml_tools: bool,
643    pub thinking_block_style: String,
644    /// Which synchronous ("live") endpoint family this route dispatches to,
645    /// when its wire dialect serves more than one. `None` means the dialect has
646    /// exactly one live endpoint, so there is nothing to select. See
647    /// [`LiveEndpointFamily`].
648    pub live_endpoint_family: Option<LiveEndpointFamily>,
649    /// Whether this route emits its reasoning INLINE in the text channel as
650    /// `<think>...</think>` blocks (local Ollama/llama.cpp reasoning models,
651    /// Qwen3 via vLLM, Kimi) rather than in a separate provider reasoning
652    /// field. When true, the `llm_call` envelope builder splits those blocks
653    /// out of `text`/`prose`/`visible_text` and folds them into the reasoning
654    /// channel, mirroring how hosted providers surface a dedicated thinking
655    /// field. Derived from `thinking_block_style == "inline"` — the same
656    /// population that represents reasoning as inline `<think>` in prompt
657    /// context is the one that emits it that way in responses.
658    pub emits_inline_reasoning: bool,
659    pub thinking_modes: Vec<String>,
660    pub interleaved_thinking_supported: bool,
661    pub anthropic_beta_features: Vec<String>,
662    pub vision_supported: bool,
663    pub image_url_input_supported: bool,
664    pub preserve_thinking: bool,
665    /// Typed provider-visible reasoning replay policy. Defaults to strip.
666    pub reasoning_round_trip: ReasoningRoundTripPolicy,
667    /// Provider-specific wire field used to replay Harn's private reasoning
668    /// on the preceding assistant turn. `None` preserves the privacy default:
669    /// reasoning is never sent back to an OpenAI-compatible provider.
670    pub reasoning_history_wire_field: Option<ReasoningHistoryWireField>,
671    pub server_parser: String,
672    pub honors_chat_template_kwargs: bool,
673    pub chat_template_options_field: Option<String>,
674    pub requires_completion_tokens: bool,
675    /// True when the route is served ONLY by the provider Responses API and
676    /// rejects `/v1/chat/completions` (OpenAI `*-codex` models). Harn routes
677    /// such calls through the Responses provider automatically.
678    pub chat_completions_unsupported: bool,
679    /// See [`ProviderRule::reasoning_tools_require_responses`].
680    pub reasoning_tools_require_responses: bool,
681    pub requires_streaming: bool,
682    pub reasoning_effort_supported: bool,
683    pub reasoning_effort_levels: Vec<String>,
684    pub reasoning_none_supported: bool,
685    /// See [`ProviderRule::max_thinking_budget`]. `None` means the model uses
686    /// the provider's own default ceiling.
687    pub max_thinking_budget: Option<i64>,
688    pub reasoning_disable_supported: bool,
689    /// See [`ProviderRule::reasoning_required_for_tools`].
690    pub reasoning_required_for_tools: bool,
691    pub reasoning_text_promotable: bool,
692    pub reasoning_wire_format: Option<String>,
693    pub seed_supported: bool,
694    pub top_k_supported: bool,
695    pub temperature_supported: bool,
696    pub top_p_supported: bool,
697    pub frequency_penalty_supported: bool,
698    pub presence_penalty_supported: bool,
699    pub stop_supported: bool,
700    pub allowed_tool_choice_modes: Vec<String>,
701    pub requires_tool_result_adjacency: bool,
702    pub supports_parallel_tool_calls: bool,
703    pub tools_exclude_response_format: bool,
704    pub recommended_endpoint: Option<String>,
705    pub text_tool_wire_format_supported: bool,
706    pub preferred_tool_format: Option<String>,
707    pub tool_mode_parity: Option<String>,
708    /// Where [`Self::tool_mode_parity`] came from.
709    ///
710    /// The verdict and its provenance used to share one slot, so a row a
711    /// human wrote a verdict for was indistinguishable from a row that got
712    /// the `unwrap_or_else` fallback (#5885). Both are *declarations* --
713    /// neither is a forced-format sweep, which lives in the catalog's
714    /// separate `tool_support.empirical_parity`.
715    pub tool_mode_parity_source: Option<ToolModeParitySource>,
716    pub tool_mode_parity_notes: Option<String>,
717    pub thinking_disable_directive: Option<String>,
718    /// Per-task auto-policy reasoning-level overrides for this route.
719    /// See [`ProviderRule::auto_reasoning_overrides`].
720    pub auto_reasoning_overrides: BTreeMap<String, String>,
721    /// OpenRouter upstream provider names to exclude from routing for this
722    /// row. See [`ProviderRule::provider_route_denylist`]. Empty means "no
723    /// route restriction".
724    pub provider_route_denylist: Vec<String>,
725    /// OpenRouter upstream provider names this row is PINNED to (allowlist), in
726    /// preference order. See [`ProviderRule::openrouter_provider_order`]. Empty
727    /// means "no pin" (free OpenRouter routing).
728    pub openrouter_provider_order: Vec<String>,
729    /// Serving-quality / precision trust verdict for this route. See
730    /// [`ProviderRule::serving_precision`]. `"unverified"` when unset.
731    pub serving_precision: String,
732    /// How the neutral `computer` tool projects onto this route's native
733    /// computer-use surface. `None` means the route exposes no computer-use
734    /// surface. See [`ComputerUseStyle`].
735    pub computer_use_style: Option<ComputerUseStyle>,
736    /// Screenshot downscaling policy applied before the image reaches the
737    /// model. `None` means unset. See [`ScreenshotScaling`].
738    pub screenshot_scaling: Option<ScreenshotScaling>,
739    /// Whether this route requires echoing acknowledged safety checks on the
740    /// computer-use follow-up turn (OpenAI Responses `pending_safety_checks`
741    /// → `acknowledged_safety_checks`). See [`ProviderRule::safety_ack_flow`].
742    pub safety_ack_flow: bool,
743    /// How this route carries an interleaved `system`/`developer` message.
744    /// `None` derives a safe default from `message_wire_format` (OpenAI/Ollama
745    /// → `Inline`, everything else → `Fold`). See [`SystemMessagePlacement`]
746    /// and [`resolve_system_message_placement`](super::resolve_system_message_placement).
747    pub system_message_placement: Option<SystemMessagePlacement>,
748}
749
750impl Default for Capabilities {
751    fn default() -> Self {
752        Self {
753            native_tools: false,
754            message_wire_format: WireDialect::OpenAiCompat,
755            native_tool_wire_format: "openai".to_string(),
756            defer_loading: false,
757            tool_search: Vec::new(),
758            responses_api: false,
759            hosted_tools: Vec::new(),
760            remote_mcp: false,
761            conversation_state: false,
762            compaction: false,
763            background_mode: false,
764            batch_api: false,
765            batch_wire_format: None,
766            batch_input_mode: None,
767            batch_discount_percent: None,
768            batch_turnaround_hours: None,
769            batch_max_requests: None,
770            batch_max_input_bytes: None,
771            batch_result_retention_days: None,
772            batch_result_ordering: None,
773            batch_partial_failure: None,
774            batch_cancellation: None,
775            batch_security_notes: Vec::new(),
776            batch_operational_notes: Vec::new(),
777            batch_regions: Vec::new(),
778            tool_approval_policy: None,
779            max_tools: None,
780            prompt_caching: false,
781            prompt_cache_ttls: Vec::new(),
782            prompt_cache_min_prefix_tokens: None,
783            cache_breakpoint_style: CacheBreakpointStyle::None,
784            vision: false,
785            audio: false,
786            pdf: false,
787            video: false,
788            files_api_supported: false,
789            file_upload_wire_format: None,
790            structured_output: None,
791            json_schema: None,
792            prefers_xml_scaffolding: false,
793            reserved_tool_call_token: false,
794            prefers_markdown_scaffolding: false,
795            structured_output_mode: "none".to_string(),
796            supports_assistant_prefill: false,
797            prefers_role_developer: false,
798            prefers_xml_tools: false,
799            thinking_block_style: "none".to_string(),
800            live_endpoint_family: None,
801            emits_inline_reasoning: false,
802            thinking_modes: Vec::new(),
803            interleaved_thinking_supported: false,
804            anthropic_beta_features: Vec::new(),
805            vision_supported: false,
806            image_url_input_supported: true,
807            preserve_thinking: false,
808            reasoning_round_trip: ReasoningRoundTripPolicy::Strip,
809            reasoning_history_wire_field: None,
810            server_parser: "none".to_string(),
811            honors_chat_template_kwargs: false,
812            chat_template_options_field: None,
813            requires_completion_tokens: false,
814            chat_completions_unsupported: false,
815            reasoning_tools_require_responses: false,
816            requires_streaming: false,
817            reasoning_effort_supported: false,
818            reasoning_effort_levels: Vec::new(),
819            reasoning_none_supported: false,
820            max_thinking_budget: None,
821            reasoning_disable_supported: true,
822            reasoning_required_for_tools: false,
823            reasoning_text_promotable: false,
824            reasoning_wire_format: None,
825            seed_supported: true,
826            top_k_supported: true,
827            temperature_supported: true,
828            top_p_supported: true,
829            frequency_penalty_supported: true,
830            presence_penalty_supported: true,
831            stop_supported: true,
832            allowed_tool_choice_modes: Vec::new(),
833            requires_tool_result_adjacency: false,
834            supports_parallel_tool_calls: true,
835            tools_exclude_response_format: false,
836            recommended_endpoint: None,
837            text_tool_wire_format_supported: true,
838            preferred_tool_format: None,
839            tool_mode_parity: None,
840            tool_mode_parity_source: None,
841            tool_mode_parity_notes: None,
842            thinking_disable_directive: None,
843            auto_reasoning_overrides: BTreeMap::new(),
844            provider_route_denylist: Vec::new(),
845            openrouter_provider_order: Vec::new(),
846            serving_precision: "unverified".to_string(),
847            computer_use_style: None,
848            screenshot_scaling: None,
849            safety_ack_flow: false,
850            system_message_placement: None,
851        }
852    }
853}