Skip to main content

harn_vm/llm/capabilities/
audit.rs

1//! Capability audit and the display/JSON provider matrix.
2//!
3//! Owns the [`ProviderCapabilityMatrixRow`] projection used by the CLI matrix
4//! surfaces (`matrix_rows`, `push_matrix_rows`, `rule_to_matrix_row`) and the
5//! tool-capability coverage audit that flags priced catalog models missing an
6//! explicit `native_tools` / `preferred_tool_format` rule
7//! (`audit_tool_capability_coverage` and the suggested-default helpers).
8
9use serde::Serialize;
10
11use super::lookup::builtin;
12use super::model::CapabilitiesFile;
13use super::overrides::current_user_overrides;
14use super::rule::{
15    first_matching_rule, rule_preferred_tool_format, rule_structured_output,
16    rule_structured_output_mode, rule_thinking_block_style, rule_thinking_modes,
17    rule_tool_mode_parity, rule_vision, MatchedCapabilityRule, ProviderRule,
18};
19use super::BUILTIN_PROVIDERS_TOML;
20
21/// Display-oriented row for `harn provider catalog matrix`, the legacy
22/// `harn check --provider-matrix` surface, and the generated docs page. Rows
23/// are intentionally rule-shaped: `model` is the rule's `model_match` pattern,
24/// because the shipped capability source of truth is a first-match rule table
25/// rather than an exhaustive remote model inventory.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27pub struct ProviderCapabilityMatrixRow {
28    pub provider: String,
29    pub model: String,
30    pub version_min: Option<Vec<u32>>,
31    /// Whether this rule opts into field-wise fall-through
32    /// ([`ProviderRule::extends`]). Rows in this matrix are rule-shaped, so
33    /// an `extends` row honestly reports its OWN fields only — for a
34    /// matching model, unset fields resolve from later matching rows and
35    /// provider defaults rather than the printed per-rule values.
36    pub extends: bool,
37    pub thinking: Vec<String>,
38    pub vision: bool,
39    pub audio: bool,
40    pub pdf: bool,
41    pub video: bool,
42    pub streaming: bool,
43    pub files_api_supported: bool,
44    pub json_schema: Option<String>,
45    pub prefers_xml_scaffolding: bool,
46    pub reserved_tool_call_token: bool,
47    pub prefers_markdown_scaffolding: bool,
48    pub structured_output_mode: String,
49    pub supports_assistant_prefill: bool,
50    pub prefers_role_developer: bool,
51    pub prefers_xml_tools: bool,
52    pub thinking_block_style: String,
53    pub native_tools: bool,
54    pub text_tools: bool,
55    pub preferred_tool_format: String,
56    pub tool_mode_parity: String,
57    /// Whether `tool_mode_parity` was authored on a row or computed from
58    /// `native_tools` / `text_tool_wire_format_supported` (#5885).
59    pub tool_mode_parity_source: String,
60    pub tools: bool,
61    pub cache: bool,
62    /// Serving-quality / precision trust verdict for this route. See
63    /// [`ProviderRule::serving_precision`]. `"unverified"` when unset.
64    pub serving_precision: String,
65    pub source: String,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
69pub struct ToolCapabilityAuditReport {
70    pub audited_models: usize,
71    pub gaps: Vec<ToolCapabilityAuditGap>,
72}
73
74impl ToolCapabilityAuditReport {
75    pub fn ok(&self) -> bool {
76        self.gaps.is_empty()
77    }
78
79    pub fn render_human(&self) -> String {
80        if self.gaps.is_empty() {
81            return format!(
82                "provider capability audit OK: {} priced chat models have explicit native_tools and preferred_tool_format rules",
83                self.audited_models
84            );
85        }
86
87        let mut out = format!(
88            "provider capability audit found {} catalog gaps among {} priced chat models:",
89            self.gaps.len(),
90            self.audited_models
91        );
92        for gap in &self.gaps {
93            let matched = match (&gap.rule_provider, &gap.rule_model_match) {
94                (Some(provider), Some(model_match)) => {
95                    format!("provider.{provider} model_match=\"{model_match}\"")
96                }
97                _ => "no matching rule".to_string(),
98            };
99            out.push_str(&format!(
100                "\n- {}:{} ({matched}) missing {}; suggest native_tools = {}, preferred_tool_format = \"{}\"",
101                gap.provider,
102                gap.model,
103                gap.missing_fields.join(", "),
104                gap.suggested_native_tools,
105                gap.suggested_preferred_tool_format,
106            ));
107        }
108        out
109    }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct ToolCapabilityAuditGap {
114    pub provider: String,
115    pub model: String,
116    pub rule_provider: Option<String>,
117    pub rule_model_match: Option<String>,
118    pub missing_fields: Vec<String>,
119    pub suggested_native_tools: bool,
120    pub suggested_preferred_tool_format: String,
121}
122
123/// Return the currently-effective provider capability rule matrix. User
124/// override rows, when installed for the current thread, are emitted before
125/// built-in rows so the display mirrors lookup precedence.
126pub fn matrix_rows() -> Vec<ProviderCapabilityMatrixRow> {
127    let user = current_user_overrides();
128    let mut rows = Vec::new();
129    if let Some(user) = user.as_ref() {
130        push_matrix_rows(&mut rows, user, "project");
131    }
132    push_matrix_rows(&mut rows, builtin(), "builtin");
133    rows
134}
135
136/// Audit the currently effective provider/model catalog against the currently
137/// effective capability rules. This is the user-facing path used by the CLI
138/// when authors are adding provider catalog or capability override rows.
139pub fn audit_catalogued_chat_model_tool_capabilities() -> ToolCapabilityAuditReport {
140    let user = current_user_overrides();
141    audit_tool_capability_coverage(
142        crate::llm_config::model_catalog_entries(),
143        builtin(),
144        user.as_ref(),
145    )
146}
147
148/// Audit the built-in catalog only. The CI test uses this path so external
149/// provider config cannot hide a gap in the shipped TOML assets.
150pub fn audit_builtin_catalogued_chat_model_tool_capabilities() -> ToolCapabilityAuditReport {
151    let catalog = crate::llm_config::parse_config_toml(BUILTIN_PROVIDERS_TOML)
152        .expect("providers.toml must parse at build time");
153    audit_tool_capability_coverage(catalog.models, builtin(), None)
154}
155
156fn audit_tool_capability_coverage<I>(
157    models: I,
158    builtin: &CapabilitiesFile,
159    user: Option<&CapabilitiesFile>,
160) -> ToolCapabilityAuditReport
161where
162    I: IntoIterator<Item = (String, crate::llm_config::ModelDef)>,
163{
164    let mut gaps = Vec::new();
165    let mut audited_models = 0;
166
167    for (model_id, model) in models {
168        if model.pricing.is_none() {
169            continue;
170        }
171        audited_models += 1;
172        let capability_model_id =
173            crate::llm_config::capability_model_id(&model.provider, &model_id);
174        let matched = first_matching_rule(user, builtin, &model.provider, &capability_model_id);
175        let mut missing_fields = Vec::new();
176        match matched.as_ref().map(|matched| &matched.rule) {
177            Some(rule) => {
178                if rule.native_tools.is_none() {
179                    missing_fields.push("native_tools".to_string());
180                }
181                if rule.preferred_tool_format.is_none() {
182                    missing_fields.push("preferred_tool_format".to_string());
183                }
184            }
185            None => {
186                missing_fields.push("native_tools".to_string());
187                missing_fields.push("preferred_tool_format".to_string());
188            }
189        }
190        if missing_fields.is_empty() {
191            continue;
192        }
193
194        let (suggested_native_tools, suggested_preferred_tool_format) =
195            suggested_tool_capability_defaults(
196                &model.provider,
197                &capability_model_id,
198                &model,
199                matched.as_ref(),
200            );
201        gaps.push(ToolCapabilityAuditGap {
202            provider: model.provider,
203            model: model_id,
204            rule_provider: matched.as_ref().map(|matched| matched.provider.clone()),
205            // Honest per-rule provenance: an `extends` fall-through chain
206            // reports every absorbed rule pattern in precedence order, not a
207            // fake single source row.
208            rule_model_match: matched.map(|matched| matched.matched_patterns.join(" -> ")),
209            missing_fields,
210            suggested_native_tools,
211            suggested_preferred_tool_format,
212        });
213    }
214
215    gaps.sort_by(|left, right| {
216        left.provider
217            .cmp(&right.provider)
218            .then_with(|| left.model.cmp(&right.model))
219    });
220    ToolCapabilityAuditReport {
221        audited_models,
222        gaps,
223    }
224}
225
226fn suggested_tool_capability_defaults(
227    provider: &str,
228    model_id: &str,
229    model: &crate::llm_config::ModelDef,
230    matched: Option<&MatchedCapabilityRule>,
231) -> (bool, String) {
232    if let Some(rule) = matched.map(|matched| &matched.rule) {
233        let native_tools = rule.native_tools.unwrap_or_else(|| {
234            // Resolve native_tools from the pinned tool_format via its channel
235            // so `json` (a TEXT-channel format) correctly implies
236            // native_tools = false, identically to `text`. Falling through to
237            // the provider heuristic for `json` would wrongly mark a gemini /
238            // cerebras row native. Unknown formats keep the heuristic.
239            match rule
240                .preferred_tool_format
241                .as_deref()
242                .and_then(crate::llm_config::tool_format_channel)
243            {
244                Some(crate::llm_config::ToolFormatChannel::Native) => true,
245                Some(crate::llm_config::ToolFormatChannel::Text) => false,
246                None => suggested_native_tools(provider, model_id, model),
247            }
248        });
249        let preferred_tool_format = rule
250            .preferred_tool_format
251            .clone()
252            .unwrap_or_else(|| tool_format_for_native(native_tools));
253        return (native_tools, preferred_tool_format);
254    }
255
256    let native_tools = suggested_native_tools(provider, model_id, model);
257    (native_tools, tool_format_for_native(native_tools))
258}
259
260fn suggested_native_tools(
261    provider: &str,
262    model_id: &str,
263    model: &crate::llm_config::ModelDef,
264) -> bool {
265    if provider == "anthropic" || model_id.contains("claude") {
266        return true;
267    }
268    if matches!(
269        provider,
270        "openai" | "gemini" | "cerebras" | "bedrock" | "azure_openai" | "vertex"
271    ) {
272        return true;
273    }
274    model
275        .capabilities
276        .iter()
277        .any(|capability| capability == "tools")
278}
279
280/// The derived `preferred_tool_format` for a capability row (or unmatched
281/// model) that does not pin one. Native-capable models derive `native`;
282/// text-channel models derive `json` (fenced-JSON), the GLOBAL text-channel
283/// default. Heredoc (`text`) is never auto-derived — it is reachable only via
284/// an explicit `preferred_tool_format = "text"` pin or an explicit request (the
285/// reverse safety valve). This is the primary default site: it fires for every
286/// model that matches a capability row without an explicit format pin.
287fn tool_format_for_native(native_tools: bool) -> String {
288    if native_tools {
289        "native".to_string()
290    } else {
291        "json".to_string()
292    }
293}
294
295fn push_matrix_rows(
296    rows: &mut Vec<ProviderCapabilityMatrixRow>,
297    file: &CapabilitiesFile,
298    source: &str,
299) {
300    for (provider, rules) in &file.provider {
301        for rule in rules {
302            rows.push(rule_to_matrix_row(provider, rule, source));
303        }
304    }
305}
306
307fn rule_to_matrix_row(
308    provider: &str,
309    rule: &ProviderRule,
310    source: &str,
311) -> ProviderCapabilityMatrixRow {
312    let (parity_verdict, parity_source) = rule_tool_mode_parity(rule);
313    ProviderCapabilityMatrixRow {
314        provider: provider.to_string(),
315        model: rule.model_match.clone(),
316        version_min: rule.version_min.clone(),
317        extends: rule.extends,
318        thinking: rule_thinking_modes(rule),
319        vision: rule_vision(rule),
320        audio: rule.audio.unwrap_or(false),
321        pdf: rule.pdf.unwrap_or(false),
322        video: rule.video.unwrap_or(false),
323        streaming: true,
324        files_api_supported: rule.files_api_supported.unwrap_or(false),
325        json_schema: rule_structured_output(rule),
326        prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
327        reserved_tool_call_token: rule.reserved_tool_call_token.unwrap_or(false),
328        prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
329        structured_output_mode: rule_structured_output_mode(rule),
330        supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
331        prefers_role_developer: rule
332            .prefers_role_developer
333            .unwrap_or_else(|| rule.requires_completion_tokens.unwrap_or(false)),
334        prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
335        thinking_block_style: rule_thinking_block_style(rule),
336        native_tools: rule.native_tools.unwrap_or(false),
337        text_tools: rule.text_tool_wire_format_supported.unwrap_or(true),
338        preferred_tool_format: rule_preferred_tool_format(rule),
339        tool_mode_parity: parity_verdict,
340        tool_mode_parity_source: parity_source.as_str().to_string(),
341        tools: rule.native_tools.unwrap_or(false)
342            || rule.text_tool_wire_format_supported.unwrap_or(true),
343        cache: rule.prompt_caching.unwrap_or(false),
344        serving_precision: rule
345            .serving_precision
346            .clone()
347            .unwrap_or_else(|| "unverified".to_string()),
348        source: source.to_string(),
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::super::lookup::clear_user_overrides;
355    use super::*;
356
357    fn reset() {
358        clear_user_overrides();
359    }
360
361    #[test]
362    fn every_catalogued_chat_model_has_explicit_tool_capabilities() {
363        reset();
364        let report = audit_builtin_catalogued_chat_model_tool_capabilities();
365        assert!(report.ok(), "{}", report.render_human());
366    }
367
368    #[test]
369    fn every_catalogued_alias_has_explicit_tool_capabilities() {
370        // The model-level audit only covers priced catalog `models`, so a
371        // `[[provider.local]]` / Ollama alias (e.g. the local gemma-4 route in
372        // Fix A) could omit native_tools/preferred_tool_format and silently
373        // degrade to text tools without tripping a test. Walk every alias's
374        // (provider, id) through the same matcher and require explicit fields.
375        reset();
376        let catalog = crate::llm_config::parse_config_toml(BUILTIN_PROVIDERS_TOML)
377            .expect("providers.toml must parse at build time");
378        let builtin = builtin();
379        let mut gaps = Vec::new();
380        for (alias, def) in &catalog.aliases {
381            let capability_model_id =
382                crate::llm_config::capability_model_id(&def.provider, &def.id);
383            let matched = first_matching_rule(None, builtin, &def.provider, &capability_model_id);
384            let explicit = matched
385                .as_ref()
386                .map(|matched| {
387                    matched.rule.native_tools.is_some()
388                        && matched.rule.preferred_tool_format.is_some()
389                })
390                .unwrap_or(false);
391            if !explicit {
392                gaps.push(format!(
393                    "{alias} -> {}:{} (rule={})",
394                    def.provider,
395                    def.id,
396                    matched
397                        .as_ref()
398                        .map(|matched| matched.rule.model_match.as_str())
399                        .unwrap_or("<none>")
400                ));
401            }
402        }
403        assert!(
404            gaps.is_empty(),
405            "aliases missing explicit native_tools/preferred_tool_format:\n- {}",
406            gaps.join("\n- ")
407        );
408    }
409
410    #[test]
411    fn tool_capability_audit_reports_suggested_defaults() {
412        reset();
413        let capabilities: CapabilitiesFile = toml::from_str(
414            r#"
415[[provider.acme]]
416model_match = "acme-good-*"
417preferred_tool_format = "native"
418"#,
419        )
420        .unwrap();
421        let report = audit_tool_capability_coverage(
422            vec![(
423                "acme-good-1".to_string(),
424                crate::llm_config::ModelDef {
425                    name: "Acme Good".to_string(),
426                    display_name: None,
427                    blurb: None,
428                    provider: "acme".to_string(),
429                    context_window: 128_000,
430                    logical_model: None,
431                    equivalence_group: None,
432                    served_variant: None,
433                    wire_model: None,
434                    api_dialect: None,
435                    rate_limits: None,
436                    performance: None,
437                    architecture: None,
438                    local_memory: None,
439                    runtime_context_window: None,
440                    stream_timeout: None,
441                    capabilities: Vec::new(),
442                    pricing: Some(crate::llm_config::ModelPricing {
443                        input_per_mtok: 1.0,
444                        output_per_mtok: 2.0,
445                        cache_read_per_mtok: None,
446                        cache_write_per_mtok: None,
447                        input_token_bands: Vec::new(),
448                    }),
449                    deprecated: false,
450                    deprecation_note: None,
451                    superseded_by: None,
452                    serving_tiers: Vec::new(),
453                    quality_tags: Vec::new(),
454                    availability: crate::llm_config::ModelAvailability::Serverless,
455                    tier: None,
456                    open_weight: None,
457                    strengths: Vec::new(),
458                    benchmarks: std::collections::BTreeMap::new(),
459                    family: None,
460                    lineage: None,
461                    complementary_with: Vec::new(),
462                    avoid_as_reviewer_for: Vec::new(),
463                },
464            )],
465            &capabilities,
466            None,
467        );
468
469        assert!(!report.ok());
470        assert_eq!(report.audited_models, 1);
471        assert_eq!(report.gaps.len(), 1);
472        assert_eq!(report.gaps[0].missing_fields, ["native_tools"]);
473        assert!(report.gaps[0].suggested_native_tools);
474        assert_eq!(report.gaps[0].suggested_preferred_tool_format, "native");
475        assert!(report.render_human().contains(
476            "acme:acme-good-1 (provider.acme model_match=\"acme-good-*\") missing native_tools; suggest native_tools = true, preferred_tool_format = \"native\""
477        ));
478    }
479
480    #[test]
481    fn matrix_rows_include_provider_patterns_and_sources() {
482        reset();
483        let rows = matrix_rows();
484        assert!(rows.iter().any(|row| {
485            row.provider == "openai"
486                && row.model == "gpt-4o*"
487                && row.vision
488                && row.audio
489                && row.json_schema.as_deref() == Some("native")
490                && row.source == "builtin"
491        }));
492    }
493}