Skip to main content

harn_vm/llm/
tool_conformance.rs

1//! One-tool provider conformance probe for local/runtime tool calling.
2//! Defines one harmless tool with stable fixture classification and a live runner.
3
4use 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    // Streaming aggregation owns this top-level list. Its raw `frames` remain
1298    // diagnostic evidence and must not become a second semantic call source.
1299    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;