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