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