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        prompt_cache_ttl: None,
669        timeout: None,
670        stream: mode == ToolProbeMode::Streaming,
671        provider_overrides: None,
672        previous_response_id: None,
673        store: None,
674        background: None,
675        truncation: None,
676        compact: None,
677        include: None,
678        max_tool_calls: None,
679        prefill: None,
680        session_id: None,
681        reminder_lifecycle: Vec::new(),
682        cli_llm_mock_scope: None,
683    }
684}
685
686fn provider_compatible_probe_request_body(payload: &LlmRequestPayload) -> Value {
687    match payload.provider.as_str() {
688        "azure_openai" => {
689            return crate::llm::providers::AzureOpenAiProvider::build_request_body(payload);
690        }
691        "bedrock" => {
692            return crate::llm::providers::BedrockProvider::build_request_body(payload);
693        }
694        "vertex" => {
695            return crate::llm::providers::VertexProvider::build_request_body(payload);
696        }
697        _ => {}
698    }
699
700    match crate::llm::capabilities::lookup(&payload.provider, &payload.model).message_wire_format {
701        WireDialect::Anthropic => {
702            crate::llm::providers::AnthropicProvider::build_request_body(payload)
703        }
704        WireDialect::Gemini => crate::llm::providers::GeminiProvider::build_request_body(payload),
705        WireDialect::Ollama => crate::llm::providers::OllamaProvider::build_request_body(payload),
706        WireDialect::OpenAiCompat => {
707            crate::llm::providers::OpenAiCompatibleProvider::build_request_body(payload, false)
708        }
709    }
710}
711
712#[derive(Debug)]
713struct NativeToolCall {
714    name: String,
715    arguments: Option<Value>,
716}
717
718fn extract_native_tool_calls(response: &Value) -> Vec<NativeToolCall> {
719    let mut calls = Vec::new();
720    visit_native_tool_call_arrays(response, &mut calls);
721    calls
722}
723
724fn visit_native_tool_call_arrays(value: &Value, calls: &mut Vec<NativeToolCall>) {
725    match value {
726        Value::Object(map) => {
727            if let Some(call) = parse_anthropic_tool_use_object(map) {
728                calls.push(call);
729            }
730            if let Some(tool_calls) = map.get("tool_calls").and_then(Value::as_array) {
731                for item in tool_calls {
732                    if let Some(call) = parse_native_tool_call(item) {
733                        calls.push(call);
734                    }
735                }
736            }
737            for child in map.values() {
738                visit_native_tool_call_arrays(child, calls);
739            }
740        }
741        Value::Array(items) => {
742            for item in items {
743                visit_native_tool_call_arrays(item, calls);
744            }
745        }
746        _ => {}
747    }
748}
749
750fn parse_anthropic_tool_use_object(
751    object: &serde_json::Map<String, Value>,
752) -> Option<NativeToolCall> {
753    if object.get("type").and_then(Value::as_str) != Some("tool_use") {
754        return None;
755    }
756    let name = object
757        .get("name")
758        .and_then(Value::as_str)
759        .filter(|name| !name.is_empty())?
760        .to_string();
761    let arguments = object
762        .get("input")
763        .or_else(|| object.get("arguments"))
764        .cloned()
765        .unwrap_or_else(|| json!({}));
766    Some(NativeToolCall {
767        name,
768        arguments: Some(arguments),
769    })
770}
771
772fn parse_native_tool_call(item: &Value) -> Option<NativeToolCall> {
773    let obj = item.as_object()?;
774    let function = obj.get("function").and_then(Value::as_object);
775    let name = function
776        .and_then(|function| function.get("name"))
777        .or_else(|| obj.get("name"))
778        .and_then(Value::as_str)?
779        .to_string();
780    match crate::llm::tools::parse_text_tool_call_from_native_name(&name) {
781        crate::llm::tools::NativeToolNameTextCall::Parsed { name, arguments } => {
782            return Some(NativeToolCall {
783                name,
784                arguments: Some(arguments),
785            });
786        }
787        crate::llm::tools::NativeToolNameTextCall::Malformed { name, .. } => {
788            return Some(NativeToolCall {
789                name,
790                arguments: None,
791            });
792        }
793        crate::llm::tools::NativeToolNameTextCall::NotCall => {}
794    }
795    let raw_args = function
796        .and_then(|function| function.get("arguments"))
797        .or_else(|| obj.get("arguments"));
798    let arguments = match raw_args {
799        Some(Value::String(raw)) => serde_json::from_str::<Value>(raw).ok(),
800        Some(value @ Value::Object(_)) => Some(value.clone()),
801        Some(_) => None,
802        None => Some(json!({})),
803    };
804    Some(NativeToolCall { name, arguments })
805}
806
807fn extract_content(response: &Value) -> String {
808    let mut parts = Vec::new();
809    visit_content(response, &mut parts);
810    parts
811        .into_iter()
812        .filter(|part| !part.trim().is_empty())
813        .collect::<Vec<_>>()
814        .join("\n")
815}
816
817fn visit_content(value: &Value, parts: &mut Vec<String>) {
818    match value {
819        Value::Object(map) => {
820            for key in ["content", "response", "text"] {
821                if let Some(text) = map.get(key).and_then(Value::as_str) {
822                    parts.push(text.to_string());
823                }
824            }
825            for child in map.values() {
826                visit_content(child, parts);
827            }
828        }
829        Value::Array(items) => {
830            for item in items {
831                visit_content(item, parts);
832            }
833        }
834        _ => {}
835    }
836}
837
838fn aggregate_stream_text(text: &str, _provider: &str) -> Value {
839    let mut content = String::new();
840    let mut calls: BTreeMap<String, PartialStreamCall> = BTreeMap::new();
841    let mut frames = Vec::new();
842    for raw_line in text.lines() {
843        let line = raw_line.trim();
844        if line.is_empty() {
845            continue;
846        }
847        let payload = line.strip_prefix("data:").map(str::trim).unwrap_or(line);
848        if payload == "[DONE]" {
849            continue;
850        }
851        let Ok(frame) = serde_json::from_str::<Value>(payload) else {
852            continue;
853        };
854        collect_stream_content_and_calls(&frame, &mut content, &mut calls);
855        frames.push(frame);
856    }
857    let tool_calls: Vec<Value> = calls
858        .into_values()
859        .map(|call| {
860            json!({
861                "id": call.id.unwrap_or_else(|| "stream_tool".to_string()),
862                "type": "function",
863                "function": {
864                    "name": call.name.unwrap_or_default(),
865                    "arguments": call.arguments,
866                }
867            })
868        })
869        .collect();
870    json!({
871        "content": content,
872        "tool_calls": tool_calls,
873        "frames": frames,
874    })
875}
876
877#[derive(Debug, Default)]
878struct PartialStreamCall {
879    id: Option<String>,
880    name: Option<String>,
881    arguments: String,
882}
883
884fn collect_stream_content_and_calls(
885    frame: &Value,
886    content: &mut String,
887    calls: &mut BTreeMap<String, PartialStreamCall>,
888) {
889    collect_anthropic_stream_content_and_calls(frame, content, calls);
890    if let Some(text) = frame
891        .pointer("/message/content")
892        .or_else(|| frame.pointer("/choices/0/delta/content"))
893        .or_else(|| frame.pointer("/choices/0/message/content"))
894        .or_else(|| frame.get("response"))
895        .and_then(Value::as_str)
896    {
897        content.push_str(text);
898    }
899    for item in frame
900        .pointer("/message/tool_calls")
901        .or_else(|| frame.pointer("/choices/0/delta/tool_calls"))
902        .or_else(|| frame.pointer("/choices/0/message/tool_calls"))
903        .and_then(Value::as_array)
904        .into_iter()
905        .flatten()
906    {
907        let key = item
908            .get("index")
909            .and_then(Value::as_u64)
910            .map(|index| index.to_string())
911            .or_else(|| item.get("id").and_then(Value::as_str).map(str::to_string))
912            .unwrap_or_else(|| calls.len().to_string());
913        let slot = calls.entry(key).or_default();
914        if let Some(id) = item.get("id").and_then(Value::as_str) {
915            slot.id = Some(id.to_string());
916        }
917        if let Some(name) = item
918            .pointer("/function/name")
919            .or_else(|| item.get("name"))
920            .and_then(Value::as_str)
921        {
922            slot.name = Some(name.to_string());
923        }
924        if let Some(arguments) = item
925            .pointer("/function/arguments")
926            .or_else(|| item.get("arguments"))
927        {
928            match arguments {
929                Value::String(delta) => slot.arguments.push_str(delta),
930                Value::Object(_) => slot.arguments = arguments.to_string(),
931                _ => {}
932            }
933        }
934    }
935}
936
937fn collect_anthropic_stream_content_and_calls(
938    frame: &Value,
939    content: &mut String,
940    calls: &mut BTreeMap<String, PartialStreamCall>,
941) {
942    match frame.get("type").and_then(Value::as_str) {
943        Some("content_block_start") => {
944            let Some(block) = frame.get("content_block") else {
945                return;
946            };
947            if block.get("type").and_then(Value::as_str) == Some("text") {
948                if let Some(text) = block.get("text").and_then(Value::as_str) {
949                    content.push_str(text);
950                }
951                return;
952            }
953            if block.get("type").and_then(Value::as_str) != Some("tool_use") {
954                return;
955            }
956            let key = frame
957                .get("index")
958                .and_then(Value::as_u64)
959                .map(|index| index.to_string())
960                .unwrap_or_else(|| calls.len().to_string());
961            let slot = calls.entry(key).or_default();
962            if let Some(id) = block.get("id").and_then(Value::as_str) {
963                slot.id = Some(id.to_string());
964            }
965            if let Some(name) = block.get("name").and_then(Value::as_str) {
966                slot.name = Some(name.to_string());
967            }
968            if let Some(input) = block.get("input").filter(|input| !input.is_null()) {
969                if !(input.is_object() && input.as_object().is_some_and(|object| object.is_empty()))
970                {
971                    slot.arguments = input.to_string();
972                }
973            }
974        }
975        Some("content_block_delta") => {
976            let key = frame
977                .get("index")
978                .and_then(Value::as_u64)
979                .map(|index| index.to_string())
980                .unwrap_or_else(|| calls.len().to_string());
981            let Some(delta) = frame.get("delta") else {
982                return;
983            };
984            match delta.get("type").and_then(Value::as_str) {
985                Some("text_delta") => {
986                    if let Some(text) = delta.get("text").and_then(Value::as_str) {
987                        content.push_str(text);
988                    }
989                }
990                Some("input_json_delta") => {
991                    if let Some(partial) = delta.get("partial_json").and_then(Value::as_str) {
992                        calls.entry(key).or_default().arguments.push_str(partial);
993                    }
994                }
995                _ => {}
996            }
997        }
998        _ => {}
999    }
1000}
1001
1002fn probe_tool_registry() -> VmValue {
1003    let mut value_param = BTreeMap::new();
1004    value_param.insert("type".to_string(), vm_str("string"));
1005    value_param.insert(
1006        "description".to_string(),
1007        vm_str("The marker value to echo."),
1008    );
1009    let mut params = BTreeMap::new();
1010    params.insert("value".to_string(), VmValue::dict(value_param));
1011    let tool = vm_dict(&[
1012        ("name", vm_str(TOOL_PROBE_TOOL_NAME)),
1013        ("description", vm_str("Echo the probe marker exactly.")),
1014        ("parameters", VmValue::dict(params)),
1015    ]);
1016    vm_dict(&[("tools", VmValue::List(std::sync::Arc::new(vec![tool])))])
1017}
1018
1019fn vm_str(value: &str) -> VmValue {
1020    VmValue::String(arcstr::ArcStr::from(value))
1021}
1022
1023fn vm_dict(pairs: &[(&str, VmValue)]) -> VmValue {
1024    let mut map = BTreeMap::new();
1025    for (key, value) in pairs {
1026        map.insert((*key).to_string(), value.clone());
1027    }
1028    VmValue::dict(map)
1029}
1030
1031fn has_raw_model_tool_tag(content: &str) -> bool {
1032    let lowered = content.to_ascii_lowercase();
1033    lowered.contains("<tool_call")
1034        || lowered.contains("<toolcall")
1035        || lowered.contains("tool_code:")
1036        || lowered.contains("tool_call:")
1037        || lowered.contains("call:")
1038        || lowered.contains("<function")
1039}
1040
1041fn content_sample(response: &Value) -> Option<String> {
1042    sample_content(&extract_content(response))
1043}
1044
1045fn sample_content(content: &str) -> Option<String> {
1046    let trimmed = content.trim();
1047    if trimmed.is_empty() {
1048        None
1049    } else {
1050        Some(trimmed.chars().take(240).collect())
1051    }
1052}
1053
1054fn sample_failure(text: &str, fallback: &str) -> String {
1055    let trimmed = text.trim();
1056    if trimmed.is_empty() {
1057        fallback.to_string()
1058    } else {
1059        format!(
1060            "{fallback}: {}",
1061            trimmed.chars().take(240).collect::<String>()
1062        )
1063    }
1064}
1065
1066fn first_non_empty(value: Option<String>, fallback: &str) -> String {
1067    value
1068        .filter(|value| !value.trim().is_empty())
1069        .unwrap_or_else(|| fallback.to_string())
1070}
1071
1072fn elapsed_ms(clock: &dyn harn_clock::Clock, started_ms: i64) -> u64 {
1073    clock.monotonic_ms().saturating_sub(started_ms).max(0) as u64
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078    use super::*;
1079
1080    #[test]
1081    fn probe_resolves_catalog_key_to_provider_wire_model() {
1082        let resolved = llm_config::resolve_model_info("baseten-glm-5.2");
1083        assert_eq!(resolved_probe_model_id(&resolved.id), "zai-org/GLM-5.2");
1084    }
1085
1086    #[test]
1087    fn probe_request_body_uses_anthropic_tool_dialect() {
1088        let body = probe_request_body(
1089            "anthropic",
1090            "claude-sonnet-4-6",
1091            ToolProbeMode::NonStreaming,
1092            DEFAULT_TOOL_PROBE_MARKER,
1093        );
1094
1095        assert_eq!(
1096            body["tools"][0]["name"], TOOL_PROBE_TOOL_NAME,
1097            "Anthropic tools use root-level names, not OpenAI function wrappers"
1098        );
1099        assert!(
1100            body["tools"][0].get("function").is_none(),
1101            "probe must not send OpenAI function-wrapper tools to Anthropic"
1102        );
1103        assert_eq!(
1104            body["tools"][0]["input_schema"]["properties"]["value"]["type"],
1105            "string"
1106        );
1107        assert_eq!(
1108            body["tool_choice"],
1109            json!({"type": "tool", "name": TOOL_PROBE_TOOL_NAME}),
1110            "Anthropic rejects OpenAI-shaped tool_choice objects"
1111        );
1112    }
1113
1114    #[test]
1115    fn probe_request_body_preserves_openai_tool_dialect() {
1116        let body = probe_request_body(
1117            "openai",
1118            "gpt-5.4-mini",
1119            ToolProbeMode::NonStreaming,
1120            DEFAULT_TOOL_PROBE_MARKER,
1121        );
1122
1123        assert_eq!(body["tools"][0]["type"], "function");
1124        assert_eq!(body["tools"][0]["function"]["name"], TOOL_PROBE_TOOL_NAME);
1125        assert_eq!(
1126            body["tool_choice"],
1127            json!({"type": "function", "function": {"name": TOOL_PROBE_TOOL_NAME}})
1128        );
1129    }
1130
1131    #[test]
1132    fn probe_request_body_maps_gemini_tool_choice_to_tool_config() {
1133        let body = probe_request_body(
1134            "gemini",
1135            "gemini-2.5-pro",
1136            ToolProbeMode::NonStreaming,
1137            DEFAULT_TOOL_PROBE_MARKER,
1138        );
1139        assert_eq!(
1140            body["tools"][0]["functionDeclarations"][0]["name"],
1141            TOOL_PROBE_TOOL_NAME
1142        );
1143        assert_eq!(body["toolConfig"]["functionCallingConfig"]["mode"], "ANY");
1144        assert_eq!(
1145            body["toolConfig"]["functionCallingConfig"]["allowedFunctionNames"],
1146            json!([TOOL_PROBE_TOOL_NAME])
1147        );
1148    }
1149
1150    #[test]
1151    fn classify_openai_native_tool_call_as_pass() {
1152        let report = classify_tool_conformance_fixture(
1153            "local",
1154            "model",
1155            ToolProbeMode::NonStreaming,
1156            DEFAULT_TOOL_PROBE_MARKER,
1157            r#"{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"echo_marker","arguments":"{\"value\":\"harn_tool_probe_marker\"}"}}]}}]}"#,
1158        );
1159        assert_eq!(report.tool_calling.native, ToolProbeStatus::Pass);
1160        assert_eq!(
1161            report.tool_calling.fallback_mode,
1162            ToolProbeFallbackMode::Native
1163        );
1164        assert_eq!(
1165            report.cases[0].classification,
1166            ToolProbeClassification::StructuredNativeToolCall
1167        );
1168    }
1169
1170    #[test]
1171    fn classify_anthropic_tool_use_as_native_pass() {
1172        let report = classify_tool_conformance_fixture(
1173            "anthropic",
1174            "claude-sonnet-4-6",
1175            ToolProbeMode::NonStreaming,
1176            DEFAULT_TOOL_PROBE_MARKER,
1177            r#"{"content":[{"type":"tool_use","id":"toolu_1","name":"echo_marker","input":{"value":"harn_tool_probe_marker"}}],"stop_reason":"tool_use"}"#,
1178        );
1179
1180        assert_eq!(report.tool_calling.native, ToolProbeStatus::Pass);
1181        assert_eq!(
1182            report.tool_calling.fallback_mode,
1183            ToolProbeFallbackMode::Native
1184        );
1185        assert_eq!(
1186            report.cases[0].classification,
1187            ToolProbeClassification::StructuredNativeToolCall
1188        );
1189    }
1190
1191    #[test]
1192    fn classify_native_tool_call_with_text_call_in_name_as_pass() {
1193        let report = classify_tool_conformance_fixture(
1194            "zai",
1195            "glm-5",
1196            ToolProbeMode::NonStreaming,
1197            DEFAULT_TOOL_PROBE_MARKER,
1198            r#"{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"echo_marker({ value: \"harn_tool_probe_marker\" })</arg_value>","arguments":"{}"}}]}}]}"#,
1199        );
1200
1201        assert_eq!(report.tool_calling.native, ToolProbeStatus::Pass);
1202        assert_eq!(
1203            report.tool_calling.fallback_mode,
1204            ToolProbeFallbackMode::Native
1205        );
1206        assert_eq!(
1207            report.cases[0].classification,
1208            ToolProbeClassification::StructuredNativeToolCall
1209        );
1210    }
1211
1212    #[test]
1213    fn classify_partial_text_call_in_native_name_as_malformed() {
1214        let report = classify_tool_conformance_fixture(
1215            "zai",
1216            "glm-5",
1217            ToolProbeMode::NonStreaming,
1218            DEFAULT_TOOL_PROBE_MARKER,
1219            r#"{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"echo_marker({ value: <<EOF","arguments":"{"}}]}}]}"#,
1220        );
1221
1222        assert_eq!(report.tool_calling.native, ToolProbeStatus::Fail);
1223        assert_eq!(
1224            report.cases[0].classification,
1225            ToolProbeClassification::MalformedJsonArguments
1226        );
1227    }
1228
1229    #[test]
1230    fn classify_gemma_raw_json_tool_call_content_as_text_fallback() {
1231        let report = classify_tool_conformance_fixture(
1232            "ollama",
1233            "gemma4:26b",
1234            ToolProbeMode::NonStreaming,
1235            DEFAULT_TOOL_PROBE_MARKER,
1236            r#"{"message":{"content":"<tool_call>{\"name\":\"echo_marker\",\"arguments\":{\"value\":\"harn_tool_probe_marker\"}}</tool_call>"}}"#,
1237        );
1238        assert_eq!(report.tool_calling.native, ToolProbeStatus::Fail);
1239        assert_eq!(report.tool_calling.text, ToolProbeStatus::Pass);
1240        assert_eq!(
1241            report.tool_calling.fallback_mode,
1242            ToolProbeFallbackMode::Text
1243        );
1244        assert_eq!(
1245            report.cases[0].classification,
1246            ToolProbeClassification::ParseableHarnTextToolCall
1247        );
1248    }
1249
1250    #[test]
1251    fn classify_qwen_call_colon_marker_as_text_fallback() {
1252        let report = classify_tool_conformance_fixture(
1253            "llamacpp",
1254            "qwen",
1255            ToolProbeMode::NonStreaming,
1256            DEFAULT_TOOL_PROBE_MARKER,
1257            r#"{"content":"call:echo_marker{ value: \"harn_tool_probe_marker\" }"}"#,
1258        );
1259        assert_eq!(report.tool_calling.text, ToolProbeStatus::Pass);
1260        assert_eq!(
1261            report.tool_calling.fallback_mode,
1262            ToolProbeFallbackMode::Text
1263        );
1264    }
1265
1266    #[test]
1267    fn classify_prose_only_as_disabled() {
1268        let report = classify_tool_conformance_fixture(
1269            "ollama",
1270            "gemma4:26b",
1271            ToolProbeMode::NonStreaming,
1272            DEFAULT_TOOL_PROBE_MARKER,
1273            r#"{"message":{"content":"The comment has been added. I will now verify it."}}"#,
1274        );
1275        assert_eq!(
1276            report.tool_calling.fallback_mode,
1277            ToolProbeFallbackMode::Disabled
1278        );
1279        assert_eq!(
1280            report.cases[0].classification,
1281            ToolProbeClassification::ProseOnlyNonTool
1282        );
1283        assert_eq!(
1284            report.cases[0].failure_reason.as_deref(),
1285            Some("no_executable_tool_call")
1286        );
1287    }
1288
1289    #[test]
1290    fn prose_only_response_does_not_satisfy_required_tool_probe() {
1291        let report = classify_tool_conformance_fixture(
1292            "anthropic",
1293            "claude-sonnet-4-6",
1294            ToolProbeMode::NonStreaming,
1295            DEFAULT_TOOL_PROBE_MARKER,
1296            r#"{"content":[{"type":"text","text":"I can call echo_marker with that value."}],"stop_reason":"end_turn"}"#,
1297        );
1298
1299        assert_eq!(
1300            report.cases[0].classification,
1301            ToolProbeClassification::ProseOnlyNonTool
1302        );
1303        assert!(!report_satisfies_required_probe(&report, "tool_probe"));
1304        assert_eq!(
1305            report.tool_calling.fallback_mode,
1306            ToolProbeFallbackMode::Disabled
1307        );
1308    }
1309
1310    #[test]
1311    fn aggregates_openai_streaming_tool_call_deltas() {
1312        let raw = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"echo_marker\",\"arguments\":\"{\\\"value\\\":\"}}]}}]}\n\
1313                   data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"harn_tool_probe_marker\\\"}\"}}]}}]}\n\
1314                   data: [DONE]\n";
1315        let response = aggregate_stream_text(raw, "local");
1316        let case = classify_tool_probe_response(
1317            ToolProbeMode::Streaming,
1318            &response,
1319            DEFAULT_TOOL_PROBE_MARKER,
1320            None,
1321            None,
1322        );
1323        assert!(case.ok, "{case:?}");
1324        assert_eq!(
1325            case.classification,
1326            ToolProbeClassification::StructuredNativeToolCall
1327        );
1328    }
1329
1330    #[test]
1331    fn aggregates_anthropic_streaming_tool_use_deltas() {
1332        let raw = "event: message_start\n\
1333                   data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[]}}\n\
1334                   event: content_block_start\n\
1335                   data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"echo_marker\",\"input\":{}}}\n\
1336                   event: content_block_delta\n\
1337                   data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"value\\\":\"}}\n\
1338                   event: content_block_delta\n\
1339                   data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"harn_tool_probe_marker\\\"}\"}}\n\
1340                   event: message_delta\n\
1341                   data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"}}\n\
1342                   event: message_stop\n\
1343                   data: {\"type\":\"message_stop\"}\n";
1344        let response = aggregate_stream_text(raw, "anthropic");
1345        let case = classify_tool_probe_response(
1346            ToolProbeMode::Streaming,
1347            &response,
1348            DEFAULT_TOOL_PROBE_MARKER,
1349            None,
1350            None,
1351        );
1352        assert!(case.ok, "{case:?}");
1353        assert_eq!(
1354            case.classification,
1355            ToolProbeClassification::StructuredNativeToolCall
1356        );
1357    }
1358
1359    #[test]
1360    fn report_satisfies_tool_probe_when_text_fallback_passes() {
1361        let report = classify_tool_conformance_fixture(
1362            "llamacpp",
1363            "qwen",
1364            ToolProbeMode::NonStreaming,
1365            DEFAULT_TOOL_PROBE_MARKER,
1366            r#"{"content":"echo_marker({ value: \"harn_tool_probe_marker\" })"}"#,
1367        );
1368        assert!(report_satisfies_required_probe(&report, "tool_probe"));
1369        assert!(!report_satisfies_required_probe(
1370            &report,
1371            "native_tool_probe"
1372        ));
1373    }
1374
1375    #[test]
1376    fn summary_requires_every_repeated_native_case_to_pass() {
1377        let summary = summarize_cases(&[
1378            probe_case(
1379                ToolProbeMode::NonStreaming,
1380                true,
1381                ToolProbeClassification::StructuredNativeToolCall,
1382            ),
1383            probe_case(
1384                ToolProbeMode::NonStreaming,
1385                false,
1386                ToolProbeClassification::ProseOnlyNonTool,
1387            ),
1388        ]);
1389        assert_eq!(summary.native, ToolProbeStatus::Fail);
1390        assert_eq!(summary.fallback_mode, ToolProbeFallbackMode::Disabled);
1391    }
1392
1393    #[test]
1394    fn summary_requires_every_repeated_text_case_to_pass() {
1395        let summary = summarize_cases(&[
1396            probe_case(
1397                ToolProbeMode::NonStreaming,
1398                true,
1399                ToolProbeClassification::ParseableHarnTextToolCall,
1400            ),
1401            probe_case(
1402                ToolProbeMode::NonStreaming,
1403                false,
1404                ToolProbeClassification::MalformedJsonArguments,
1405            ),
1406        ]);
1407        assert_eq!(summary.native, ToolProbeStatus::Fail);
1408        assert_eq!(summary.text, ToolProbeStatus::Fail);
1409        assert_eq!(summary.fallback_mode, ToolProbeFallbackMode::Disabled);
1410    }
1411
1412    #[test]
1413    fn summary_preserves_nonstreaming_text_fallback_when_streaming_fails() {
1414        let summary = summarize_cases(&[
1415            probe_case(
1416                ToolProbeMode::NonStreaming,
1417                true,
1418                ToolProbeClassification::ParseableHarnTextToolCall,
1419            ),
1420            probe_case(
1421                ToolProbeMode::Streaming,
1422                false,
1423                ToolProbeClassification::ProseOnlyNonTool,
1424            ),
1425        ]);
1426        assert_eq!(summary.native, ToolProbeStatus::Fail);
1427        assert_eq!(summary.streaming_native, ToolProbeStatus::Fail);
1428        assert_eq!(summary.text, ToolProbeStatus::Pass);
1429        assert_eq!(summary.fallback_mode, ToolProbeFallbackMode::Text);
1430    }
1431
1432    fn probe_case(
1433        mode: ToolProbeMode,
1434        ok: bool,
1435        classification: ToolProbeClassification,
1436    ) -> ToolConformanceCase {
1437        let native_tool_call_count =
1438            usize::from(classification == ToolProbeClassification::StructuredNativeToolCall);
1439        let text_tool_call_count =
1440            usize::from(classification == ToolProbeClassification::ParseableHarnTextToolCall);
1441        ToolConformanceCase {
1442            mode,
1443            ok,
1444            classification,
1445            fallback_mode: ToolProbeFallbackMode::Disabled,
1446            failure_reason: None,
1447            http_status: None,
1448            elapsed_ms: None,
1449            native_tool_call_count,
1450            text_tool_call_count,
1451            parser_errors: Vec::new(),
1452            protocol_violations: Vec::new(),
1453            content_sample: None,
1454        }
1455    }
1456}