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