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 tools: bool,
58 pub cache: bool,
59 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
120pub 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
133pub 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
145pub 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 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 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
277fn 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 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}