1use 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#[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 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 tool_mode_parity_source: String,
60 pub tools: bool,
61 pub cache: bool,
62 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
123pub 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
136pub 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
148pub 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 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 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
280fn 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 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}