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