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