1use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12
13use crate::llm_config::{self, ProviderDef};
14
15#[path = "tool_conformance_helpers.rs"]
16mod helpers;
17#[path = "tool_conformance_request.rs"]
18mod request;
19use super::usage_normalization::extract_probe_usage;
20pub use super::usage_normalization::ToolProbeUsage;
21pub(super) use helpers::{aggregate_stream_text, probe_tool_registry};
22use request::{probe_request_body, validate_probe_request_body};
23
24pub const TOOL_CONFORMANCE_SCHEMA_VERSION: u32 = 1;
25pub const TOOL_CONFORMANCE_REQUEST_SCHEMA_VERSION: u32 = 3;
26pub const TOOL_CONFORMANCE_REQUEST_AUDIT_SCHEMA_VERSION: u32 = 4;
27pub const TOOL_PROBE_TOOL_NAME: &str = "echo_marker";
28pub const DEFAULT_TOOL_PROBE_MARKER: &str = "harn_tool_probe_marker";
29
30#[derive(Debug, Clone)]
31pub struct ToolConformanceProbeOptions {
32 pub provider: String,
33 pub model: String,
34 pub base_url: Option<String>,
35 pub modes: Vec<ToolProbeMode>,
36 pub probe_case: ToolProbeCase,
37 pub marker: String,
38 pub repeat: usize,
39 pub timeout_secs: u64,
40}
41
42impl ToolConformanceProbeOptions {
43 pub fn new(provider: impl Into<String>, model: impl Into<String>) -> Self {
44 Self {
45 provider: provider.into(),
46 model: model.into(),
47 base_url: None,
48 modes: vec![ToolProbeMode::NonStreaming, ToolProbeMode::Streaming],
49 probe_case: ToolProbeCase::SingleToolCall,
50 marker: DEFAULT_TOOL_PROBE_MARKER.to_string(),
51 repeat: 1,
52 timeout_secs: 120,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum ToolProbeMode {
60 NonStreaming,
61 Streaming,
62}
63
64impl ToolProbeMode {
65 pub fn as_str(self) -> &'static str {
66 match self {
67 Self::NonStreaming => "non_streaming",
68 Self::Streaming => "streaming",
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum ToolProbeCase {
76 #[default]
77 SingleToolCall,
78 ParallelToolCalls,
79 LargeStringArgument,
80 ToolResultFollowup,
81 SignedThinkingToolResultFollowup,
82 NoToolAnswerOrRefusal,
83 UnavailableToolRepair,
84 DoneSentinel,
85}
86
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum ToolProbeRequestProfile {
90 #[default]
91 CatalogDefault,
92 ParameterEdges,
93}
94
95impl ToolProbeRequestProfile {
96 pub fn as_str(self) -> &'static str {
97 match self {
98 Self::CatalogDefault => "catalog_default",
99 Self::ParameterEdges => "parameter_edges",
100 }
101 }
102
103 pub fn catalog_request_audit_profiles() -> Vec<Self> {
104 vec![Self::CatalogDefault, Self::ParameterEdges]
105 }
106}
107
108impl ToolProbeCase {
109 pub fn as_str(self) -> &'static str {
110 match self {
111 Self::SingleToolCall => "single_tool_call",
112 Self::ParallelToolCalls => "parallel_tool_calls",
113 Self::LargeStringArgument => "large_string_argument",
114 Self::ToolResultFollowup => "tool_result_followup",
115 Self::SignedThinkingToolResultFollowup => "signed_thinking_tool_result_followup",
116 Self::NoToolAnswerOrRefusal => "no_tool_answer_or_refusal",
117 Self::UnavailableToolRepair => "unavailable_tool_repair",
118 Self::DoneSentinel => "done_sentinel",
119 }
120 }
121
122 pub fn catalog_request_audit_cases() -> Vec<Self> {
123 vec![
124 Self::SingleToolCall,
125 Self::ParallelToolCalls,
126 Self::LargeStringArgument,
127 Self::ToolResultFollowup,
128 Self::SignedThinkingToolResultFollowup,
129 Self::NoToolAnswerOrRefusal,
130 Self::UnavailableToolRepair,
131 Self::DoneSentinel,
132 ]
133 }
134
135 pub fn is_live_applicable(self, provider: &str, model: &str) -> bool {
136 match self {
137 Self::SignedThinkingToolResultFollowup => {
138 crate::llm::tool_scorecard::signed_thinking_tool_history_supported(provider, model)
139 }
140 _ => true,
141 }
142 }
143
144 fn expected_value(self, marker: &str) -> String {
145 match self {
146 Self::SingleToolCall => marker.to_string(),
147 Self::ParallelToolCalls => marker.to_string(),
148 Self::LargeStringArgument => format!(
149 "marker={marker}\nquoted=\"value\"\njson={{\"marker\":{marker:?},\"nested\":[1,true]}}\nheredoc=<<EOF\n{marker}\nEOF\nunicode=\\u{{2603}}\\u{{1F680}}\nend"
150 ),
151 Self::ToolResultFollowup => format!("tool_result_followup:{marker}"),
152 Self::SignedThinkingToolResultFollowup => {
153 format!("signed_thinking_tool_result_followup:{marker}")
154 }
155 Self::NoToolAnswerOrRefusal => format!("direct_answer:{marker}"),
156 Self::UnavailableToolRepair => format!("unavailable_tool:{marker}"),
157 Self::DoneSentinel => format!("<done>{marker}</done>"),
158 }
159 }
160
161 fn requires_probe_tool(self) -> bool {
162 matches!(
163 self,
164 Self::SingleToolCall | Self::ParallelToolCalls | Self::LargeStringArgument
165 )
166 }
167
168 fn request_uses_probe_tool(self) -> bool {
169 self.requires_probe_tool()
170 || matches!(
171 self,
172 Self::ToolResultFollowup | Self::SignedThinkingToolResultFollowup
173 )
174 }
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ToolConformanceRequestReport {
179 pub schema_version: u32,
180 pub provider: String,
181 pub model: String,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub base_url: Option<String>,
184 #[serde(default)]
185 pub probe_case: ToolProbeCase,
186 #[serde(default)]
187 pub request_profile: ToolProbeRequestProfile,
188 pub tool_name: String,
189 pub marker: String,
190 pub expected_value: String,
191 pub requests: Vec<ToolConformanceRequestCase>,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ToolConformanceRequestCase {
196 pub mode: ToolProbeMode,
197 pub request_body: Value,
198 pub validation: ToolConformanceRequestValidation,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct ToolConformanceRequestValidation {
203 pub dialect: String,
204 pub status: ToolConformanceRequestValidationStatus,
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
206 pub issues: Vec<String>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct ToolConformanceRequestAuditReport {
211 pub schema_version: u32,
212 pub catalog_model_count: usize,
213 pub route_count: usize,
214 pub probe_cases: Vec<String>,
215 pub request_profiles: Vec<String>,
216 pub modes: Vec<String>,
217 pub request_count: usize,
218 pub validation_pass_count: usize,
219 pub validation_fail_count: usize,
220 pub not_applicable_count: usize,
221 pub dialect_counts: BTreeMap<String, usize>,
222 pub provider_counts: BTreeMap<String, usize>,
223 pub routes: Vec<ToolConformanceRequestAuditRoute>,
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
225 pub failures: Vec<ToolConformanceRequestAuditFailure>,
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub not_applicable: Vec<ToolConformanceRequestAuditNotApplicable>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ToolConformanceRequestAuditRoute {
232 pub provider: String,
233 pub model: String,
234 pub request_count: usize,
235 pub validation_pass_count: usize,
236 pub validation_fail_count: usize,
237 pub not_applicable_count: usize,
238 pub dialect_counts: BTreeMap<String, usize>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ToolConformanceRequestAuditFailure {
243 pub provider: String,
244 pub model: String,
245 pub probe_case: String,
246 pub request_profile: String,
247 pub mode: String,
248 pub dialect: String,
249 pub issues: Vec<String>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct ToolConformanceRequestAuditNotApplicable {
254 pub provider: String,
255 pub model: String,
256 pub probe_case: String,
257 pub request_profile: String,
258 pub mode: String,
259 pub dialect: String,
260 pub reason: String,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264#[serde(rename_all = "snake_case")]
265pub enum ToolConformanceRequestValidationStatus {
266 Pass,
267 Fail,
268 NotApplicable,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "snake_case")]
273pub enum ToolProbeClassification {
274 StructuredNativeToolCall,
275 ParseableHarnTextToolCall,
276 DirectAnswerNoTool,
277 UnavailableToolRepair,
278 DoneSentinel,
279 RawModelToolTag,
280 ProseOnlyNonTool,
281 MalformedJsonArguments,
282 EmptySilent,
283 HttpError,
284 TransportError,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "snake_case")]
289pub enum ToolProbeStatus {
290 Pass,
291 Fail,
292 Unknown,
293}
294
295impl ToolProbeStatus {
296 pub fn as_str(&self) -> &'static str {
297 match self {
298 Self::Pass => "pass",
299 Self::Fail => "fail",
300 Self::Unknown => "unknown",
301 }
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "snake_case")]
307pub enum ToolProbeFallbackMode {
308 Native,
309 Text,
310 Disabled,
311}
312
313impl ToolProbeFallbackMode {
314 pub fn as_str(&self) -> &'static str {
315 match self {
316 Self::Native => "native",
317 Self::Text => "text",
318 Self::Disabled => "disabled",
319 }
320 }
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct ToolConformanceReport {
325 pub schema_version: u32,
326 pub provider: String,
327 pub model: String,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub base_url: Option<String>,
330 #[serde(default)]
331 pub probe_case: ToolProbeCase,
332 pub tool_name: String,
333 pub marker: String,
334 #[serde(default)]
335 pub expected_value: String,
336 pub cases: Vec<ToolConformanceCase>,
337 pub tool_calling: ToolCallingConformanceSummary,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct ToolCallingConformanceSummary {
342 pub native: ToolProbeStatus,
343 pub text: ToolProbeStatus,
344 pub streaming_native: ToolProbeStatus,
345 pub fallback_mode: ToolProbeFallbackMode,
346 #[serde(skip_serializing_if = "Option::is_none")]
347 pub failure_reason: Option<String>,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct ToolConformanceCase {
352 pub mode: ToolProbeMode,
353 pub ok: bool,
354 pub classification: ToolProbeClassification,
355 pub fallback_mode: ToolProbeFallbackMode,
356 #[serde(skip_serializing_if = "Option::is_none")]
357 pub failure_reason: Option<String>,
358 #[serde(skip_serializing_if = "Option::is_none")]
359 pub http_status: Option<u16>,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 pub elapsed_ms: Option<u64>,
362 pub native_tool_call_count: usize,
363 pub text_tool_call_count: usize,
364 #[serde(skip_serializing_if = "Option::is_none")]
365 pub usage: Option<ToolProbeUsage>,
366 #[serde(default, skip_serializing_if = "Vec::is_empty")]
367 pub parser_errors: Vec<String>,
368 #[serde(default, skip_serializing_if = "Vec::is_empty")]
369 pub protocol_violations: Vec<String>,
370 #[serde(skip_serializing_if = "Option::is_none")]
371 pub content_sample: Option<String>,
372}
373
374impl ToolConformanceCase {
375 fn transport_error(mode: ToolProbeMode, message: String, elapsed_ms: Option<u64>) -> Self {
376 Self {
377 mode,
378 ok: false,
379 classification: ToolProbeClassification::TransportError,
380 fallback_mode: ToolProbeFallbackMode::Disabled,
381 failure_reason: Some(message),
382 http_status: None,
383 elapsed_ms,
384 native_tool_call_count: 0,
385 text_tool_call_count: 0,
386 usage: None,
387 parser_errors: Vec::new(),
388 protocol_violations: Vec::new(),
389 content_sample: None,
390 }
391 }
392
393 fn http_error(
394 mode: ToolProbeMode,
395 status: u16,
396 message: String,
397 elapsed_ms: Option<u64>,
398 ) -> Self {
399 Self {
400 mode,
401 ok: false,
402 classification: ToolProbeClassification::HttpError,
403 fallback_mode: ToolProbeFallbackMode::Disabled,
404 failure_reason: Some(message),
405 http_status: Some(status),
406 elapsed_ms,
407 native_tool_call_count: 0,
408 text_tool_call_count: 0,
409 usage: None,
410 parser_errors: Vec::new(),
411 protocol_violations: Vec::new(),
412 content_sample: None,
413 }
414 }
415}
416
417pub async fn run_tool_conformance_probe(
418 options: ToolConformanceProbeOptions,
419) -> ToolConformanceReport {
420 let model = llm_config::resolve_model_info(&options.model);
421 let provider = if options.provider.trim().is_empty() {
422 model.provider.clone()
423 } else {
424 options.provider.clone()
425 };
426 let model_id = resolved_probe_model_id(&model.id);
427 let base_url = options.base_url.clone().or_else(|| {
428 llm_config::provider_config(&provider).map(|def| llm_config::resolve_base_url(&def))
429 });
430 let mut cases = Vec::new();
431 let modes = normalized_modes(&options.modes);
432 let expected_value = options.probe_case.expected_value(&options.marker);
433 for _ in 0..options.repeat.max(1) {
434 for mode in &modes {
435 cases.push(
436 execute_live_probe_case(
437 &provider,
438 &model_id,
439 base_url.as_deref(),
440 *mode,
441 options.probe_case,
442 &expected_value,
443 options.timeout_secs,
444 )
445 .await,
446 );
447 }
448 }
449 report_from_cases(
450 provider,
451 model_id,
452 base_url,
453 options.probe_case,
454 options.marker,
455 expected_value,
456 cases,
457 )
458}
459
460fn resolved_probe_model_id(selector: &str) -> String {
461 llm_config::wire_model_id(selector)
462}
463
464pub fn classify_tool_conformance_fixture(
465 provider: impl Into<String>,
466 model: impl Into<String>,
467 mode: ToolProbeMode,
468 marker: impl Into<String>,
469 raw: &str,
470) -> ToolConformanceReport {
471 classify_tool_conformance_fixture_for_case(
472 provider,
473 model,
474 mode,
475 ToolProbeCase::SingleToolCall,
476 marker,
477 raw,
478 )
479}
480
481pub fn classify_tool_conformance_fixture_for_case(
482 provider: impl Into<String>,
483 model: impl Into<String>,
484 mode: ToolProbeMode,
485 probe_case: ToolProbeCase,
486 marker: impl Into<String>,
487 raw: &str,
488) -> ToolConformanceReport {
489 let marker = marker.into();
490 let expected_value = probe_case.expected_value(&marker);
491 let provider = provider.into();
492 let model = model.into();
493 let response = serde_json::from_str::<Value>(raw).unwrap_or_else(|_| json!({ "content": raw }));
494 let usage = extract_probe_usage(&provider, &model, &response);
495 let case = classify_tool_probe_response(
496 mode,
497 &response,
498 probe_case,
499 &expected_value,
500 None,
501 None,
502 usage,
503 );
504 report_from_cases(
505 provider,
506 model,
507 None,
508 probe_case,
509 marker,
510 expected_value,
511 vec![case],
512 )
513}
514
515pub fn tool_conformance_request_report(
516 provider: impl Into<String>,
517 model: impl Into<String>,
518 base_url: Option<String>,
519 modes: Vec<ToolProbeMode>,
520 probe_case: ToolProbeCase,
521 request_profile: ToolProbeRequestProfile,
522 marker: impl Into<String>,
523) -> Result<ToolConformanceRequestReport, String> {
524 let provider = provider.into();
525 let model = model.into();
526 let marker = marker.into();
527 let expected_value = probe_case.expected_value(&marker);
528 let mut requests = Vec::new();
529 for mode in normalized_modes(&modes) {
530 let request_body = probe_request_body(
531 &provider,
532 &model,
533 mode,
534 probe_case,
535 request_profile,
536 &expected_value,
537 )?;
538 requests.push(ToolConformanceRequestCase {
539 mode,
540 validation: validate_probe_request_body(
541 &provider,
542 &model,
543 probe_case,
544 request_profile,
545 &request_body,
546 ),
547 request_body,
548 });
549 }
550 Ok(ToolConformanceRequestReport {
551 schema_version: TOOL_CONFORMANCE_REQUEST_SCHEMA_VERSION,
552 provider,
553 model,
554 base_url,
555 probe_case,
556 request_profile,
557 tool_name: TOOL_PROBE_TOOL_NAME.to_string(),
558 marker,
559 expected_value,
560 requests,
561 })
562}
563
564pub fn tool_conformance_request_report_json(
565 provider: impl Into<String>,
566 model: impl Into<String>,
567 base_url: Option<String>,
568 modes: Vec<ToolProbeMode>,
569 probe_case: ToolProbeCase,
570 request_profile: ToolProbeRequestProfile,
571 marker: impl Into<String>,
572) -> Result<String, String> {
573 let report = tool_conformance_request_report(
574 provider,
575 model,
576 base_url,
577 modes,
578 probe_case,
579 request_profile,
580 marker,
581 )?;
582 serde_json::to_string_pretty(&report).map_err(|error| {
583 format!("internal error: failed to render tool-probe request report: {error}")
584 })
585}
586
587pub fn tool_conformance_request_catalog_audit(
588 probe_cases: Vec<ToolProbeCase>,
589 request_profiles: Vec<ToolProbeRequestProfile>,
590 modes: Vec<ToolProbeMode>,
591) -> ToolConformanceRequestAuditReport {
592 let probe_cases = if probe_cases.is_empty() {
593 ToolProbeCase::catalog_request_audit_cases()
594 } else {
595 probe_cases
596 };
597 let modes = normalized_modes(&modes);
598 let request_profiles = if request_profiles.is_empty() {
599 ToolProbeRequestProfile::catalog_request_audit_profiles()
600 } else {
601 normalized_request_profiles(&request_profiles)
602 };
603 let catalog_model_count = llm_config::model_catalog_entries().len();
604 let routing_routes = crate::provider_catalog::artifact().routing_routes;
605 let mut request_count = 0usize;
606 let mut validation_pass_count = 0usize;
607 let mut validation_fail_count = 0usize;
608 let mut not_applicable_count = 0usize;
609 let mut dialect_counts = BTreeMap::new();
610 let mut provider_counts = BTreeMap::new();
611 let mut routes = Vec::new();
612 let mut failures = Vec::new();
613 let mut not_applicable = Vec::new();
614
615 for catalog_route in &routing_routes {
616 let mut route = ToolConformanceRequestAuditRoute {
617 provider: catalog_route.provider.clone(),
618 model: catalog_route.model.clone(),
619 request_count: 0,
620 validation_pass_count: 0,
621 validation_fail_count: 0,
622 not_applicable_count: 0,
623 dialect_counts: BTreeMap::new(),
624 };
625 for probe_case in &probe_cases {
626 for request_profile in &request_profiles {
627 for mode in &modes {
628 route.request_count += 1;
629 request_count += 1;
630 *provider_counts
631 .entry(catalog_route.provider.clone())
632 .or_insert(0) += 1;
633 match tool_conformance_request_report(
634 catalog_route.provider.clone(),
635 catalog_route.model.clone(),
636 None,
637 vec![*mode],
638 *probe_case,
639 *request_profile,
640 DEFAULT_TOOL_PROBE_MARKER,
641 ) {
642 Ok(report) => {
643 for request in report.requests {
644 let dialect = request.validation.dialect.clone();
645 *dialect_counts.entry(dialect.clone()).or_insert(0) += 1;
646 *route.dialect_counts.entry(dialect.clone()).or_insert(0) += 1;
647 match request.validation.status {
648 ToolConformanceRequestValidationStatus::Pass => {
649 route.validation_pass_count += 1;
650 validation_pass_count += 1;
651 }
652 ToolConformanceRequestValidationStatus::Fail => {
653 route.validation_fail_count += 1;
654 validation_fail_count += 1;
655 failures.push(ToolConformanceRequestAuditFailure {
656 provider: catalog_route.provider.clone(),
657 model: catalog_route.model.clone(),
658 probe_case: probe_case.as_str().to_string(),
659 request_profile: request_profile.as_str().to_string(),
660 mode: mode.as_str().to_string(),
661 dialect,
662 issues: request.validation.issues,
663 });
664 }
665 ToolConformanceRequestValidationStatus::NotApplicable => {
666 route.not_applicable_count += 1;
667 not_applicable_count += 1;
668 not_applicable.push(
669 ToolConformanceRequestAuditNotApplicable {
670 provider: catalog_route.provider.clone(),
671 model: catalog_route.model.clone(),
672 probe_case: probe_case.as_str().to_string(),
673 request_profile: request_profile
674 .as_str()
675 .to_string(),
676 mode: mode.as_str().to_string(),
677 dialect,
678 reason: request
679 .validation
680 .issues
681 .first()
682 .cloned()
683 .unwrap_or_else(|| {
684 "probe case is not applicable to this route"
685 .to_string()
686 }),
687 },
688 );
689 }
690 }
691 }
692 }
693 Err(error) => {
694 route.validation_fail_count += 1;
695 validation_fail_count += 1;
696 failures.push(ToolConformanceRequestAuditFailure {
697 provider: catalog_route.provider.clone(),
698 model: catalog_route.model.clone(),
699 probe_case: probe_case.as_str().to_string(),
700 request_profile: request_profile.as_str().to_string(),
701 mode: mode.as_str().to_string(),
702 dialect: "request_build".to_string(),
703 issues: vec![error],
704 });
705 }
706 }
707 }
708 }
709 }
710 routes.push(route);
711 }
712
713 ToolConformanceRequestAuditReport {
714 schema_version: TOOL_CONFORMANCE_REQUEST_AUDIT_SCHEMA_VERSION,
715 catalog_model_count,
716 route_count: routes.len(),
717 probe_cases: probe_cases
718 .into_iter()
719 .map(|probe_case| probe_case.as_str().to_string())
720 .collect(),
721 request_profiles: request_profiles
722 .into_iter()
723 .map(|request_profile| request_profile.as_str().to_string())
724 .collect(),
725 modes: modes
726 .into_iter()
727 .map(|mode| mode.as_str().to_string())
728 .collect(),
729 request_count,
730 validation_pass_count,
731 validation_fail_count,
732 not_applicable_count,
733 dialect_counts,
734 provider_counts,
735 routes,
736 failures,
737 not_applicable,
738 }
739}
740
741pub fn report_satisfies_required_probe(report: &ToolConformanceReport, requirement: &str) -> bool {
742 match requirement {
743 "tool_probe" | "tool_call_probe" => {
744 report.tool_calling.fallback_mode != ToolProbeFallbackMode::Disabled
745 && report.cases.iter().any(|case| case.ok)
746 }
747 "native_tool_probe" => report.tool_calling.native == ToolProbeStatus::Pass,
748 "streaming_tool_probe" => report.tool_calling.streaming_native == ToolProbeStatus::Pass,
749 _ => false,
750 }
751}
752
753fn normalized_modes(modes: &[ToolProbeMode]) -> Vec<ToolProbeMode> {
754 if modes.is_empty() {
755 return vec![ToolProbeMode::NonStreaming, ToolProbeMode::Streaming];
756 }
757 let mut out = Vec::new();
758 for mode in modes {
759 if !out.contains(mode) {
760 out.push(*mode);
761 }
762 }
763 out
764}
765
766fn normalized_request_profiles(
767 profiles: &[ToolProbeRequestProfile],
768) -> Vec<ToolProbeRequestProfile> {
769 if profiles.is_empty() {
770 return ToolProbeRequestProfile::catalog_request_audit_profiles();
771 }
772 let mut out = Vec::new();
773 for profile in profiles {
774 if !out.contains(profile) {
775 out.push(*profile);
776 }
777 }
778 out
779}
780
781fn report_from_cases(
782 provider: String,
783 model: String,
784 base_url: Option<String>,
785 probe_case: ToolProbeCase,
786 marker: String,
787 expected_value: String,
788 cases: Vec<ToolConformanceCase>,
789) -> ToolConformanceReport {
790 let summary = summarize_cases(&cases);
791 ToolConformanceReport {
792 schema_version: TOOL_CONFORMANCE_SCHEMA_VERSION,
793 provider,
794 model,
795 base_url,
796 probe_case,
797 tool_name: TOOL_PROBE_TOOL_NAME.to_string(),
798 marker,
799 expected_value,
800 cases,
801 tool_calling: summary,
802 }
803}
804
805fn summarize_cases(cases: &[ToolConformanceCase]) -> ToolCallingConformanceSummary {
806 let native = summarize_native_mode(cases, ToolProbeMode::NonStreaming);
807 let streaming_native = summarize_native_mode(cases, ToolProbeMode::Streaming);
808 let text = summarize_text_mode(cases);
809
810 let fallback_mode =
811 if native == ToolProbeStatus::Pass || streaming_native == ToolProbeStatus::Pass {
812 ToolProbeFallbackMode::Native
813 } else if text == ToolProbeStatus::Pass {
814 ToolProbeFallbackMode::Text
815 } else {
816 ToolProbeFallbackMode::Disabled
817 };
818
819 let failure_reason = if fallback_mode == ToolProbeFallbackMode::Disabled {
820 cases.iter().find_map(|case| case.failure_reason.clone())
821 } else {
822 None
823 };
824
825 ToolCallingConformanceSummary {
826 native,
827 text,
828 streaming_native,
829 fallback_mode,
830 failure_reason,
831 }
832}
833
834fn summarize_native_mode(cases: &[ToolConformanceCase], mode: ToolProbeMode) -> ToolProbeStatus {
835 let mut saw_mode = false;
836 let mut all_passed = true;
837 for case in cases.iter().filter(|case| case.mode == mode) {
838 saw_mode = true;
839 if !(case.ok && case.classification == ToolProbeClassification::StructuredNativeToolCall) {
840 all_passed = false;
841 }
842 }
843 match (saw_mode, all_passed) {
844 (false, _) => ToolProbeStatus::Unknown,
845 (true, true) => ToolProbeStatus::Pass,
846 (true, false) => ToolProbeStatus::Fail,
847 }
848}
849
850fn summarize_text_mode(cases: &[ToolConformanceCase]) -> ToolProbeStatus {
851 let mut saw_text = false;
852 let mut saw_passing_mode = false;
853 for mode in [ToolProbeMode::NonStreaming, ToolProbeMode::Streaming] {
854 let mut saw_mode = false;
855 let mut saw_text_in_mode = false;
856 let mut all_mode_cases_passed = true;
857 for case in cases.iter().filter(|case| case.mode == mode) {
858 saw_mode = true;
859 saw_text_in_mode |= case.classification
860 == ToolProbeClassification::ParseableHarnTextToolCall
861 || case.text_tool_call_count > 0;
862 if !(case.ok
863 && case.classification == ToolProbeClassification::ParseableHarnTextToolCall)
864 {
865 all_mode_cases_passed = false;
866 }
867 }
868 saw_text |= saw_text_in_mode;
869 if saw_mode && saw_text_in_mode && all_mode_cases_passed {
870 saw_passing_mode = true;
871 }
872 }
873 if !saw_text {
874 return ToolProbeStatus::Unknown;
875 }
876 if saw_passing_mode {
877 ToolProbeStatus::Pass
878 } else {
879 ToolProbeStatus::Fail
880 }
881}
882
883async fn execute_live_probe_case(
884 provider: &str,
885 model: &str,
886 base_url: Option<&str>,
887 mode: ToolProbeMode,
888 probe_case: ToolProbeCase,
889 marker: &str,
890 timeout_secs: u64,
891) -> ToolConformanceCase {
892 let clock = harn_clock::RealClock::arc();
893 let started_ms = clock.monotonic_ms();
894 let Some(def) = llm_config::provider_config(provider) else {
895 return ToolConformanceCase::transport_error(
896 mode,
897 format!("unknown provider: {provider}"),
898 Some(elapsed_ms(&*clock, started_ms)),
899 );
900 };
901 let base_url = base_url
902 .filter(|value| !value.trim().is_empty())
903 .map(str::to_string)
904 .unwrap_or_else(|| llm_config::resolve_base_url(&def));
905 let url = match chat_url(&def, &base_url) {
906 Ok(url) => url,
907 Err(message) => {
908 return ToolConformanceCase::transport_error(
909 mode,
910 message,
911 Some(elapsed_ms(&*clock, started_ms)),
912 );
913 }
914 };
915 let body = match probe_request_body(
916 provider,
917 model,
918 mode,
919 probe_case,
920 ToolProbeRequestProfile::CatalogDefault,
921 marker,
922 ) {
923 Ok(body) => body,
924 Err(message) => {
925 return ToolConformanceCase::transport_error(
926 mode,
927 message,
928 Some(elapsed_ms(&*clock, started_ms)),
929 );
930 }
931 };
932 let client = if mode == ToolProbeMode::Streaming {
933 crate::llm::streaming_client_for_base_url(&base_url)
934 } else {
935 crate::llm::blocking_client_for_base_url(&base_url)
936 };
937 let api_key = crate::llm::resolve_api_key(provider).unwrap_or_default();
938 let request = client
939 .post(&url)
940 .header("Content-Type", "application/json")
941 .timeout(std::time::Duration::from_secs(timeout_secs))
942 .json(&body);
943 let mut request = crate::llm::api::apply_auth_headers(request, &api_key, Some(&def));
944 for (name, value) in &def.extra_headers {
945 request = request.header(name.as_str(), value.as_str());
946 }
947
948 let response = match request.send().await {
949 Ok(response) => response,
950 Err(error) => {
951 return ToolConformanceCase::transport_error(
952 mode,
953 format!("provider request failed: {error}"),
954 Some(elapsed_ms(&*clock, started_ms)),
955 );
956 }
957 };
958 let status = response.status();
959 let text = match response.text().await {
960 Ok(text) => text,
961 Err(error) => {
962 return ToolConformanceCase::transport_error(
963 mode,
964 format!("provider response was unreadable: {error}"),
965 Some(elapsed_ms(&*clock, started_ms)),
966 );
967 }
968 };
969 let elapsed = Some(elapsed_ms(&*clock, started_ms));
970 if !status.is_success() {
971 return ToolConformanceCase::http_error(
972 mode,
973 status.as_u16(),
974 sample_failure(&text, "provider returned non-success HTTP status"),
975 elapsed,
976 );
977 }
978 let response_value = if mode == ToolProbeMode::Streaming {
979 aggregate_stream_text(&text, provider)
980 } else {
981 serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "content": text }))
982 };
983 let usage = extract_probe_usage(provider, model, &response_value);
984 classify_tool_probe_response(
985 mode,
986 &response_value,
987 probe_case,
988 marker,
989 Some(status.as_u16()),
990 elapsed,
991 usage,
992 )
993}
994
995fn probe_marker_present(calls: &[Value], marker: &str) -> bool {
999 probe_values_present(calls, &[marker.to_string()])
1000}
1001
1002fn probe_values_present(calls: &[Value], expected_values: &[String]) -> bool {
1003 expected_values
1004 .iter()
1005 .all(|expected| probe_value_count(calls, expected) > 0)
1006}
1007
1008fn probe_value_count(calls: &[Value], marker: &str) -> usize {
1009 calls.iter().any(|call| {
1010 call.get("name").and_then(Value::as_str) == Some(TOOL_PROBE_TOOL_NAME)
1011 && call
1012 .get("arguments")
1013 .and_then(|args| args.get("value"))
1014 .and_then(Value::as_str)
1015 == Some(marker)
1016 }) as usize
1017}
1018
1019fn expected_tool_values(probe_case: ToolProbeCase, expected_value: &str) -> Vec<String> {
1020 match probe_case {
1021 ToolProbeCase::ParallelToolCalls => vec![
1022 format!("{expected_value}:first"),
1023 format!("{expected_value}:second"),
1024 ],
1025 _ => vec![expected_value.to_string()],
1026 }
1027}
1028
1029fn classify_tool_probe_response(
1030 mode: ToolProbeMode,
1031 response: &Value,
1032 probe_case: ToolProbeCase,
1033 expected_value: &str,
1034 http_status: Option<u16>,
1035 elapsed_ms: Option<u64>,
1036 usage: Option<ToolProbeUsage>,
1037) -> ToolConformanceCase {
1038 let native = extract_native_tool_calls(response);
1039 let native_count = native.len();
1040 let mut malformed_native = false;
1041 let expected_tool_values = expected_tool_values(probe_case, expected_value);
1042 let mut native_probe_calls = Vec::new();
1043 for call in &native {
1044 if call.name == TOOL_PROBE_TOOL_NAME {
1045 match &call.arguments {
1046 Some(Value::Object(map)) => {
1047 if let Some(value) = map.get("value").and_then(Value::as_str) {
1048 native_probe_calls.push(value.to_string());
1049 }
1050 }
1051 _ => malformed_native = true,
1052 }
1053 }
1054 }
1055 let native_pass = probe_case.requires_probe_tool()
1056 && expected_tool_values
1057 .iter()
1058 .all(|expected| native_probe_calls.contains(expected))
1059 && (probe_case != ToolProbeCase::ParallelToolCalls
1060 || (native_probe_calls.len() == expected_tool_values.len() && !malformed_native));
1061 if native_pass {
1062 return ToolConformanceCase {
1063 mode,
1064 ok: true,
1065 classification: ToolProbeClassification::StructuredNativeToolCall,
1066 fallback_mode: ToolProbeFallbackMode::Native,
1067 failure_reason: None,
1068 http_status,
1069 elapsed_ms,
1070 native_tool_call_count: native_count,
1071 text_tool_call_count: 0,
1072 usage,
1073 parser_errors: Vec::new(),
1074 protocol_violations: Vec::new(),
1075 content_sample: content_sample(response),
1076 };
1077 }
1078
1079 let content = extract_content(response);
1080 let tools = probe_tool_registry();
1081 let tagged = crate::llm::tools::parse_text_tool_calls_with_tools(&content, Some(&tools));
1082 let parsed = if probe_marker_present(&tagged.calls, expected_value) {
1083 tagged
1084 } else {
1085 let fenced = crate::llm::tools::parse_fenced_json_tool_calls(&content);
1086 if probe_marker_present(&fenced.calls, expected_value) {
1087 fenced
1088 } else {
1089 tagged
1090 }
1091 };
1092 let text_count = parsed.calls.len();
1093 if !probe_case.requires_probe_tool() {
1094 return classify_no_tool_probe_response(
1095 mode,
1096 probe_case,
1097 expected_value,
1098 &content,
1099 (native_count, text_count),
1100 parsed,
1101 (http_status, elapsed_ms),
1102 usage,
1103 );
1104 }
1105 let text_pass = if probe_case == ToolProbeCase::ParallelToolCalls {
1106 probe_values_present(&parsed.calls, &expected_tool_values)
1107 && parsed.calls.len() == expected_tool_values.len()
1108 } else {
1109 probe_marker_present(&parsed.calls, expected_value)
1110 };
1111 if text_pass {
1112 return ToolConformanceCase {
1113 mode,
1114 ok: true,
1115 classification: ToolProbeClassification::ParseableHarnTextToolCall,
1116 fallback_mode: ToolProbeFallbackMode::Text,
1117 failure_reason: None,
1118 http_status,
1119 elapsed_ms,
1120 native_tool_call_count: native_count,
1121 text_tool_call_count: text_count,
1122 usage,
1123 parser_errors: parsed.errors,
1124 protocol_violations: parsed.violations,
1125 content_sample: sample_content(&content),
1126 };
1127 }
1128
1129 let (classification, failure_reason) = if malformed_native || !parsed.errors.is_empty() {
1130 (
1131 ToolProbeClassification::MalformedJsonArguments,
1132 Some(first_non_empty(
1133 parsed.errors.first().cloned(),
1134 "malformed_tool_arguments",
1135 )),
1136 )
1137 } else if content.trim().is_empty() && native_count == 0 {
1138 (
1139 ToolProbeClassification::EmptySilent,
1140 Some("empty_silent_response".to_string()),
1141 )
1142 } else if has_raw_model_tool_tag(&content) {
1143 (
1144 ToolProbeClassification::RawModelToolTag,
1145 Some("raw_tool_tag_no_structured_calls".to_string()),
1146 )
1147 } else {
1148 (
1149 ToolProbeClassification::ProseOnlyNonTool,
1150 Some("no_executable_tool_call".to_string()),
1151 )
1152 };
1153
1154 ToolConformanceCase {
1155 mode,
1156 ok: false,
1157 classification,
1158 fallback_mode: ToolProbeFallbackMode::Disabled,
1159 failure_reason,
1160 http_status,
1161 elapsed_ms,
1162 native_tool_call_count: native_count,
1163 text_tool_call_count: text_count,
1164 usage,
1165 parser_errors: parsed.errors,
1166 protocol_violations: parsed.violations,
1167 content_sample: sample_content(&content),
1168 }
1169}
1170
1171fn classify_no_tool_probe_response(
1172 mode: ToolProbeMode,
1173 probe_case: ToolProbeCase,
1174 expected_value: &str,
1175 content: &str,
1176 counts: (usize, usize),
1177 parsed: crate::llm::tools::TextToolParseResult,
1178 timing: (Option<u16>, Option<u64>),
1179 usage: Option<ToolProbeUsage>,
1180) -> ToolConformanceCase {
1181 let (native_count, text_count) = counts;
1182 let (http_status, elapsed_ms) = timing;
1183 let unexpected_tool = native_count > 0 || text_count > 0;
1184 let empty = content.trim().is_empty() && !unexpected_tool;
1185 let ok = !unexpected_tool && !empty && content.contains(expected_value);
1186 let classification = if unexpected_tool {
1187 ToolProbeClassification::RawModelToolTag
1188 } else if empty {
1189 ToolProbeClassification::EmptySilent
1190 } else if ok {
1191 match probe_case {
1192 ToolProbeCase::NoToolAnswerOrRefusal => ToolProbeClassification::DirectAnswerNoTool,
1193 ToolProbeCase::UnavailableToolRepair => ToolProbeClassification::UnavailableToolRepair,
1194 ToolProbeCase::DoneSentinel => ToolProbeClassification::DoneSentinel,
1195 _ => ToolProbeClassification::ProseOnlyNonTool,
1196 }
1197 } else if has_raw_model_tool_tag(content) {
1198 ToolProbeClassification::RawModelToolTag
1199 } else {
1200 ToolProbeClassification::ProseOnlyNonTool
1201 };
1202 let failure_reason = (!ok).then(|| {
1203 if unexpected_tool {
1204 "unexpected_tool_call".to_string()
1205 } else if empty {
1206 "empty_silent_response".to_string()
1207 } else {
1208 format!("missing_expected_text:{expected_value}")
1209 }
1210 });
1211 ToolConformanceCase {
1212 mode,
1213 ok,
1214 classification,
1215 fallback_mode: ToolProbeFallbackMode::Disabled,
1216 failure_reason,
1217 http_status,
1218 elapsed_ms,
1219 native_tool_call_count: native_count,
1220 text_tool_call_count: text_count,
1221 usage,
1222 parser_errors: parsed.errors,
1223 protocol_violations: parsed.violations,
1224 content_sample: sample_content(content),
1225 }
1226}
1227
1228fn chat_url(def: &ProviderDef, base_url: &str) -> Result<String, String> {
1229 let endpoint = if def.chat_endpoint.trim().is_empty() {
1230 "/v1/chat/completions"
1231 } else {
1232 def.chat_endpoint.as_str()
1233 };
1234 let url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
1235 endpoint.to_string()
1236 } else if endpoint.starts_with('/') {
1237 format!("{}{}", base_url.trim_end_matches('/'), endpoint)
1238 } else {
1239 format!("{}/{}", base_url.trim_end_matches('/'), endpoint)
1240 };
1241 reqwest::Url::parse(&url)
1242 .map(|_| url.clone())
1243 .map_err(|error| format!("invalid provider chat URL '{url}': {error}"))
1244}
1245
1246#[derive(Debug)]
1247struct NativeToolCall {
1248 name: String,
1249 arguments: Option<Value>,
1250}
1251
1252fn extract_native_tool_calls(response: &Value) -> Vec<NativeToolCall> {
1253 let mut calls = Vec::new();
1254 visit_native_tool_call_arrays(response, &mut calls);
1255 calls
1256}
1257
1258fn visit_native_tool_call_arrays(value: &Value, calls: &mut Vec<NativeToolCall>) {
1259 match value {
1260 Value::Object(map) => {
1261 if let Some(call) = parse_anthropic_tool_use_object(map) {
1262 calls.push(call);
1263 }
1264 if let Some(tool_calls) = map.get("tool_calls").and_then(Value::as_array) {
1265 for item in tool_calls {
1266 if let Some(call) = parse_native_tool_call(item) {
1267 calls.push(call);
1268 }
1269 }
1270 }
1271 for child in map.values() {
1272 visit_native_tool_call_arrays(child, calls);
1273 }
1274 }
1275 Value::Array(items) => {
1276 for item in items {
1277 visit_native_tool_call_arrays(item, calls);
1278 }
1279 }
1280 _ => {}
1281 }
1282}
1283
1284fn parse_anthropic_tool_use_object(
1285 object: &serde_json::Map<String, Value>,
1286) -> Option<NativeToolCall> {
1287 if object.get("type").and_then(Value::as_str) != Some("tool_use") {
1288 return None;
1289 }
1290 let name = object
1291 .get("name")
1292 .and_then(Value::as_str)
1293 .filter(|name| !name.is_empty())?
1294 .to_string();
1295 let arguments = object
1296 .get("input")
1297 .or_else(|| object.get("arguments"))
1298 .cloned()
1299 .unwrap_or_else(|| json!({}));
1300 Some(NativeToolCall {
1301 name,
1302 arguments: Some(arguments),
1303 })
1304}
1305
1306fn parse_native_tool_call(item: &Value) -> Option<NativeToolCall> {
1307 let obj = item.as_object()?;
1308 let function = obj.get("function").and_then(Value::as_object);
1309 let name = function
1310 .and_then(|function| function.get("name"))
1311 .or_else(|| obj.get("name"))
1312 .and_then(Value::as_str)?
1313 .to_string();
1314 match crate::llm::tools::parse_text_tool_call_from_native_name(&name) {
1315 crate::llm::tools::NativeToolNameTextCall::Parsed { name, arguments } => {
1316 return Some(NativeToolCall {
1317 name,
1318 arguments: Some(arguments),
1319 });
1320 }
1321 crate::llm::tools::NativeToolNameTextCall::Malformed { name, .. } => {
1322 return Some(NativeToolCall {
1323 name,
1324 arguments: None,
1325 });
1326 }
1327 crate::llm::tools::NativeToolNameTextCall::NotCall => {}
1328 }
1329 let raw_args = function
1330 .and_then(|function| function.get("arguments"))
1331 .or_else(|| obj.get("arguments"));
1332 let arguments = match raw_args {
1333 Some(Value::String(raw)) => serde_json::from_str::<Value>(raw).ok(),
1334 Some(value @ Value::Object(_)) => Some(value.clone()),
1335 Some(_) => None,
1336 None => Some(json!({})),
1337 };
1338 Some(NativeToolCall { name, arguments })
1339}
1340
1341fn extract_content(response: &Value) -> String {
1342 let mut parts = Vec::new();
1343 visit_content(response, &mut parts);
1344 parts
1345 .into_iter()
1346 .filter(|part| !part.trim().is_empty())
1347 .collect::<Vec<_>>()
1348 .join("\n")
1349}
1350
1351fn visit_content(value: &Value, parts: &mut Vec<String>) {
1352 match value {
1353 Value::Object(map) => {
1354 if object_is_private_reasoning_content(map) {
1355 return;
1356 }
1357 for key in ["content", "response", "text"] {
1358 if let Some(text) = map.get(key).and_then(Value::as_str) {
1359 parts.push(text.to_string());
1360 }
1361 }
1362 for (key, child) in map {
1363 if field_is_private_reasoning(key) {
1364 continue;
1365 }
1366 visit_content(child, parts);
1367 }
1368 }
1369 Value::Array(items) => {
1370 for item in items {
1371 visit_content(item, parts);
1372 }
1373 }
1374 _ => {}
1375 }
1376}
1377
1378fn object_is_private_reasoning_content(map: &serde_json::Map<String, Value>) -> bool {
1379 let block_type = map.get("type").and_then(Value::as_str).unwrap_or("");
1380 if matches!(block_type, "reasoning" | "thinking" | "reasoning_summary") {
1381 return true;
1382 }
1383 matches!(
1384 map.get("visibility").and_then(Value::as_str),
1385 Some("private" | "internal")
1386 )
1387}
1388
1389fn field_is_private_reasoning(field: &str) -> bool {
1390 matches!(
1391 field,
1392 "analysis"
1393 | "reasoning"
1394 | "reasoning_content"
1395 | "reasoning_details"
1396 | "reasoning_summary"
1397 | "thinking"
1398 | "thinking_summary"
1399 )
1400}
1401
1402fn has_raw_model_tool_tag(content: &str) -> bool {
1403 let lowered = content.to_ascii_lowercase();
1404 lowered.contains("<tool_call")
1405 || lowered.contains("<toolcall")
1406 || lowered.contains("tool_code:")
1407 || lowered.contains("tool_call:")
1408 || lowered.contains("call:")
1409 || lowered.contains("<function")
1410}
1411
1412fn content_sample(response: &Value) -> Option<String> {
1413 sample_content(&extract_content(response))
1414}
1415
1416fn sample_content(content: &str) -> Option<String> {
1417 let trimmed = content.trim();
1418 if trimmed.is_empty() {
1419 None
1420 } else {
1421 Some(trimmed.chars().take(240).collect())
1422 }
1423}
1424
1425fn sample_failure(text: &str, fallback: &str) -> String {
1426 let trimmed = text.trim();
1427 if trimmed.is_empty() {
1428 fallback.to_string()
1429 } else {
1430 format!(
1431 "{fallback}: {}",
1432 trimmed.chars().take(240).collect::<String>()
1433 )
1434 }
1435}
1436
1437fn first_non_empty(value: Option<String>, fallback: &str) -> String {
1438 value
1439 .filter(|value| !value.trim().is_empty())
1440 .unwrap_or_else(|| fallback.to_string())
1441}
1442
1443fn elapsed_ms(clock: &dyn harn_clock::Clock, started_ms: i64) -> u64 {
1444 clock.monotonic_ms().saturating_sub(started_ms).max(0) as u64
1445}
1446
1447#[cfg(test)]
1448#[path = "tool_conformance_tests.rs"]
1449mod tests;