Skip to main content

harn_vm/llm/
tool_conformance.rs

1//! One-tool provider conformance probe for local/runtime tool calling.
2//!
3//! The probe is deliberately tiny: define one harmless `echo_marker` tool,
4//! ask the model to call it with a fixed marker, and classify what came back.
5//! The classification is the stable contract eval harnesses consume; the live
6//! HTTP runner is a convenience around that classifier.
7
8use 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
995/// True when `calls` contains the probe's echo_marker call (the
996/// `TOOL_PROBE_TOOL_NAME` tool with `args.value == marker`). Shared by the
997/// tagged and fenced-JSON text-channel parse attempts.
998fn 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;