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 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}