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