1use std::collections::{BTreeMap, BTreeSet};
12use std::process;
13
14use serde::Serialize;
15
16use crate::cli::{
17 ProviderDispatchAuditArgs, ProviderDispatchAuditVariantArg, ProviderDispatchExplainArgs,
18 ProviderToolProbeCaseArg,
19};
20
21pub const DISPATCH_AUDIT_SCHEMA_VERSION: u8 = 4;
28
29#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
30pub(crate) struct DispatchExplanation {
31 pub provider: String,
32 pub model: String,
33 pub wire_format: String,
34 pub message_wire_format: String,
35 #[serde(skip_serializing_if = "Option::is_none")]
38 pub live_endpoint_family: Option<String>,
39 pub native_tool_wire_format: String,
40 pub base_url_host: String,
41 pub tool_format: String,
42 pub native_tools: bool,
43 pub structured_output: Option<String>,
44 pub structured_output_mode: String,
45 pub advertises_thinking: bool,
46 pub thinking_modes: Vec<String>,
47 pub requested_thinking: bool,
48 pub thinking_note: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
52struct DispatchAuditRow {
53 id: String,
54 variant: String,
55 #[serde(flatten)]
56 explanation: DispatchExplanation,
57}
58
59#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
60struct DispatchAuditFailure {
61 code: String,
62 message: String,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 provider: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 route: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 filter_kind: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 filter_value: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
74struct DispatchAuditCatalogProvenance {
75 hash_blake3: String,
76 provider_count: usize,
77 model_count: usize,
78 routing_route_count: usize,
79}
80
81#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
82struct DispatchAuditUnroutedProvider {
83 provider: String,
84 model_count: usize,
85 active_model_count: usize,
86 route_count: usize,
87 reason: String,
88}
89
90#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
91struct DispatchAuditReport {
92 schema_version: u8,
93 catalog: DispatchAuditCatalogProvenance,
94 route_count: usize,
95 variant_count: usize,
96 row_count: usize,
97 pass_count: usize,
98 fail_count: usize,
99 unrouted_provider_count: usize,
100 #[serde(skip_serializing_if = "Vec::is_empty")]
101 unrouted_providers: Vec<DispatchAuditUnroutedProvider>,
102 providers: Vec<String>,
103 variants: Vec<String>,
104 rows: Vec<DispatchAuditRow>,
105 failures: Vec<DispatchAuditFailure>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 tool_probe_plan: Option<DispatchAuditToolProbePlan>,
108}
109
110#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
111struct DispatchAuditToolProbePlan {
112 schema_version: u8,
113 plan_id: String,
114 catalog_hash_blake3: String,
115 matrix: DispatchAuditToolProbeMatrix,
116 readiness_command_count: usize,
117 command_count: usize,
118 request_audit_command_count: usize,
119 route_count: usize,
120 cases: Vec<String>,
121 #[serde(skip_serializing_if = "Vec::is_empty")]
122 excluded_cases: Vec<String>,
123 #[serde(skip_serializing_if = "Vec::is_empty")]
124 not_applicable_commands: Vec<DispatchAuditToolProbeNotApplicable>,
125 live_request_profiles: Vec<String>,
126 request_audit_profiles: Vec<String>,
127 modes: Vec<String>,
128 repeat: u16,
129 timeout_secs: u64,
130 output_dir: String,
131 readiness_commands: Vec<DispatchAuditReadinessCommand>,
132 commands: Vec<DispatchAuditToolProbeCommand>,
133 request_audit_commands: Vec<DispatchAuditToolProbeRequestAuditCommand>,
134}
135
136#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
137struct DispatchAuditToolProbeMatrix {
138 provider_count: usize,
139 model_count: usize,
140 provider_model_count: usize,
141 route_count: usize,
142 case_count: usize,
143 mode_count: usize,
144 live_request_profile_count: usize,
145 request_audit_profile_count: usize,
146 readiness_command_count: usize,
147 command_count: usize,
148 request_audit_command_count: usize,
149 not_applicable_count: usize,
150}
151
152#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
153struct DispatchAuditReadinessCommand {
154 id: String,
155 route: String,
156 provider: String,
157 model: String,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 structured_output: Option<String>,
160 structured_output_mode: String,
161 #[serde(skip_serializing_if = "Vec::is_empty")]
162 secret_envs: Vec<String>,
163 argv: Vec<String>,
164 output_path: String,
165}
166
167#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
168struct DispatchAuditToolProbeNotApplicable {
169 route: String,
170 provider: String,
171 model: String,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 structured_output: Option<String>,
174 structured_output_mode: String,
175 #[serde(skip_serializing_if = "Vec::is_empty")]
176 secret_envs: Vec<String>,
177 case: String,
178 request_profile: String,
179 mode: String,
180 reason: String,
181}
182
183#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
184struct DispatchAuditToolProbeCommand {
185 id: String,
186 route: String,
187 provider: String,
188 model: String,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 structured_output: Option<String>,
191 structured_output_mode: String,
192 #[serde(skip_serializing_if = "Vec::is_empty")]
193 secret_envs: Vec<String>,
194 case: String,
195 request_profile: String,
196 mode: String,
197 repeat: u16,
198 timeout_secs: u64,
199 argv: Vec<String>,
200 output_path: String,
201}
202
203#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
204struct DispatchAuditToolProbeRequestAuditCommand {
205 id: String,
206 route: String,
207 provider: String,
208 model: String,
209 #[serde(skip_serializing_if = "Option::is_none")]
210 structured_output: Option<String>,
211 structured_output_mode: String,
212 #[serde(skip_serializing_if = "Vec::is_empty")]
213 secret_envs: Vec<String>,
214 case: String,
215 request_profile: String,
216 mode: String,
217 argv: Vec<String>,
218 output_path: String,
219}
220
221struct DispatchAuditToolProbeCommandContext<'a> {
222 catalog_hash: &'a str,
223 output_dir: &'a str,
224 secret_envs_by_provider: &'a BTreeMap<String, Vec<String>>,
225 repeat: u16,
226 timeout_secs: u64,
227}
228
229pub(crate) fn run(args: &ProviderDispatchExplainArgs) {
230 let resolved = harn_vm::llm_config::resolve_model_info(&args.model);
231 if resolved.alias.is_some() && resolved.provider != args.provider {
232 eprintln!(
233 "error: model selector `{}` resolves to provider `{}`, not requested provider `{}`",
234 args.model, resolved.provider, args.provider
235 );
236 std::process::exit(1);
237 }
238 let model = resolved
239 .alias
240 .as_ref()
241 .map_or(args.model.as_str(), |_| resolved.id.as_str());
242 let report = explain(
243 &args.provider,
244 model,
245 args.thinking,
246 args.tool_format.as_deref(),
247 );
248 if args.json {
249 print_json_or_exit(&report, "dispatch explanation");
250 return;
251 }
252
253 print_human_explanation(&report);
254}
255
256pub(crate) fn run_audit(args: &ProviderDispatchAuditArgs) {
257 let report = audit(args);
258 if args.json {
259 print_json_or_exit(&report, "dispatch audit report");
260 } else {
261 println!(
262 "provider dispatch audit: {}/{} dispatch rows passed across {} catalog routes and {} variants",
263 report.pass_count, report.row_count, report.route_count, report.variant_count
264 );
265 if let Some(plan) = &report.tool_probe_plan {
266 println!(
267 "tool-probe plan: {} live commands and {} request-audit commands across {} routes, {} cases, {} modes",
268 plan.command_count,
269 plan.request_audit_command_count,
270 plan.route_count,
271 plan.cases.len(),
272 plan.modes.len()
273 );
274 }
275 for failure in report.failures.iter().take(10) {
276 let target = failure
277 .route
278 .as_ref()
279 .or(failure.provider.as_ref())
280 .map(|value| format!(" {value}"))
281 .unwrap_or_default();
282 println!("- {}{}", failure.code, target);
283 println!(" {}", failure.message);
284 }
285 }
286 if report.fail_count > 0 {
287 process::exit(1);
288 }
289}
290
291pub(crate) fn explain(
292 provider: &str,
293 model: &str,
294 requested_thinking: bool,
295 tool_format_override: Option<&str>,
296) -> DispatchExplanation {
297 let caps = harn_vm::llm::capabilities::lookup(provider, model);
298 let wire_format = harn_vm::llm::resolved_dispatch::wire_format_for(provider, model);
299 let message_wire_format = caps.message_wire_format.as_str().to_string();
300 let live_endpoint_family = caps
301 .live_endpoint_family
302 .map(|family| family.as_str().to_string());
303 let base_url = harn_vm::llm_config::provider_config(provider)
304 .map(|def| harn_vm::llm_config::resolve_base_url(&def))
305 .unwrap_or_else(|| default_base_url_for(wire_format));
306 let base_url_host = host_of(&base_url);
307
308 let tool_format = tool_format_override
309 .map(str::to_string)
310 .or_else(|| caps.preferred_tool_format.clone())
311 .unwrap_or_else(|| {
312 if caps.native_tools {
313 "native".to_string()
314 } else {
315 "text".to_string()
316 }
317 });
318
319 let advertises_thinking = !caps.thinking_modes.is_empty();
323 let thinking_note = if requested_thinking && !advertises_thinking {
324 Some(format!(
325 "requested --thinking but {provider}:{model} advertises no thinking modes; the route will not return reasoning content"
326 ))
327 } else {
328 None
329 };
330
331 DispatchExplanation {
332 provider: provider.to_string(),
333 model: model.to_string(),
334 wire_format: wire_format.to_string(),
335 message_wire_format,
336 live_endpoint_family,
337 native_tool_wire_format: caps.native_tool_wire_format,
338 base_url_host,
339 tool_format,
340 native_tools: caps.native_tools,
341 structured_output: caps.structured_output,
342 structured_output_mode: caps.structured_output_mode,
343 advertises_thinking,
344 thinking_modes: caps.thinking_modes,
345 requested_thinking,
346 thinking_note,
347 }
348}
349
350fn audit(args: &ProviderDispatchAuditArgs) -> DispatchAuditReport {
351 let variants = dispatch_audit_variants(&args.variants);
352 let mut failures = route_filter_failures(&args.routes);
353 let artifact = harn_vm::provider_catalog::artifact();
354 let catalog = catalog_provenance(&artifact);
355 let unrouted_providers = unrouted_providers(&artifact);
356 let selected_routes = selected_routes(args, &artifact.routing_routes);
357 failures.extend(provider_filter_failures(
358 args,
359 &artifact,
360 &unrouted_providers,
361 ));
362 failures.extend(model_filter_failures(args, &artifact, &selected_routes));
363 failures.extend(capability_filter_failures(args, &selected_routes));
364 failures.extend(missing_route_filter_failures(args, &selected_routes));
365 let providers: Vec<String> = selected_routes
366 .iter()
367 .map(|route| route.provider.clone())
368 .collect::<BTreeSet<_>>()
369 .into_iter()
370 .collect();
371 let mut rows = Vec::new();
372 for route in &selected_routes {
373 for variant in &variants {
374 let explanation = explain(
375 &route.provider,
376 &route.model,
377 variant.requested_thinking(),
378 variant.tool_format_override(),
379 );
380 rows.push(DispatchAuditRow {
381 id: stable_id(&["dispatch", &route.provider, &route.model, variant.name()]),
382 variant: variant.name().to_string(),
383 explanation,
384 });
385 }
386 }
387 if rows.is_empty() && failures.is_empty() {
388 failures.push(DispatchAuditFailure {
389 code: "no_routes_selected".to_string(),
390 message: "filters selected zero catalog routing routes".to_string(),
391 provider: None,
392 route: None,
393 filter_kind: None,
394 filter_value: None,
395 });
396 }
397 let fail_count = failures.len();
398 let tool_probe_plan = if args.include_tool_probe_plan {
399 Some(tool_probe_plan(
400 args,
401 &selected_routes,
402 &catalog.hash_blake3,
403 &artifact.providers,
404 ))
405 } else {
406 None
407 };
408 DispatchAuditReport {
409 schema_version: DISPATCH_AUDIT_SCHEMA_VERSION,
410 catalog,
411 route_count: selected_routes.len(),
412 variant_count: variants.len(),
413 row_count: rows.len(),
414 pass_count: rows.len(),
415 fail_count,
416 unrouted_provider_count: unrouted_providers.len(),
417 unrouted_providers,
418 providers,
419 variants: variants
420 .into_iter()
421 .map(|variant| variant.name().to_string())
422 .collect(),
423 rows,
424 failures,
425 tool_probe_plan,
426 }
427}
428
429fn tool_probe_plan(
430 args: &ProviderDispatchAuditArgs,
431 selected_routes: &[harn_vm::provider_catalog::CatalogRoutingRoute],
432 catalog_hash: &str,
433 providers: &[harn_vm::provider_catalog::CatalogProvider],
434) -> DispatchAuditToolProbePlan {
435 let case_selection = tool_probe_plan_cases(&args.tool_probe_cases);
436 let modes = args.tool_probe_mode.tool_probe_modes();
437 let live_request_profiles = vec!["catalog_default".to_string()];
438 let request_audit_profiles = vec!["parameter_edges".to_string()];
439 let output_dir = args
440 .tool_probe_output_dir
441 .clone()
442 .unwrap_or_else(|| default_tool_probe_output_dir(catalog_hash));
443 let route_fingerprint = selected_routes
444 .iter()
445 .map(route_key)
446 .collect::<Vec<_>>()
447 .join("\0");
448 let case_fingerprint = case_selection
449 .included
450 .iter()
451 .map(|case| case.as_str())
452 .collect::<Vec<_>>()
453 .join("\0");
454 let mode_fingerprint = modes
455 .iter()
456 .map(|mode| mode.as_str())
457 .collect::<Vec<_>>()
458 .join("\0");
459 let live_profile_fingerprint = live_request_profiles.join("\0");
460 let request_audit_profile_fingerprint = request_audit_profiles.join("\0");
461 let repeat_fingerprint = args.tool_probe_repeat.to_string();
462 let timeout_fingerprint = args.tool_probe_timeout_secs.to_string();
463 let readiness_fingerprint = "provider_ready";
464 let plan_id = stable_id(&[
465 "tool_probe_plan",
466 catalog_hash,
467 &route_fingerprint,
468 &case_fingerprint,
469 &mode_fingerprint,
470 &live_profile_fingerprint,
471 &request_audit_profile_fingerprint,
472 &repeat_fingerprint,
473 &timeout_fingerprint,
474 readiness_fingerprint,
475 &output_dir,
476 ]);
477 let secret_envs_by_provider = catalog_secret_envs_by_provider(providers);
478 let readiness_commands = tool_probe_readiness_commands(
479 catalog_hash,
480 &output_dir,
481 selected_routes,
482 &secret_envs_by_provider,
483 );
484 let mut commands = Vec::new();
485 let mut request_audit_commands = Vec::new();
486 let mut not_applicable_commands = Vec::new();
487 let command_context = DispatchAuditToolProbeCommandContext {
488 catalog_hash,
489 output_dir: &output_dir,
490 secret_envs_by_provider: &secret_envs_by_provider,
491 repeat: args.tool_probe_repeat,
492 timeout_secs: args.tool_probe_timeout_secs,
493 };
494 for route in selected_routes {
495 for case in &case_selection.included {
496 for mode in &modes {
497 let case_name = case.as_str();
498 let mode_name = mode.as_str();
499 if !case.is_live_applicable(&route.provider, &route.model) {
500 let (structured_output, structured_output_mode) =
501 route_structured_output_contract(route);
502 for request_profile in live_request_profiles
503 .iter()
504 .chain(request_audit_profiles.iter())
505 {
506 not_applicable_commands.push(DispatchAuditToolProbeNotApplicable {
507 route: route_key(route),
508 provider: route.provider.clone(),
509 model: route.model.clone(),
510 structured_output: structured_output.clone(),
511 structured_output_mode: structured_output_mode.clone(),
512 secret_envs: secret_envs_for_route(route, &secret_envs_by_provider),
513 case: case_name.to_string(),
514 request_profile: request_profile.clone(),
515 mode: mode_name.to_string(),
516 reason: "route_has_no_signed_thinking_tool_history_surface".to_string(),
517 });
518 }
519 continue;
520 }
521 for request_profile in &live_request_profiles {
522 commands.push(live_tool_probe_command(
523 &command_context,
524 route,
525 case_name,
526 request_profile,
527 mode_name,
528 *mode,
529 ));
530 }
531 for request_profile in &request_audit_profiles {
532 request_audit_commands.push(request_audit_tool_probe_command(
533 &command_context,
534 route,
535 case_name,
536 request_profile,
537 mode_name,
538 *mode,
539 ));
540 }
541 }
542 }
543 }
544 let provider_count = selected_routes
545 .iter()
546 .map(|route| route.provider.as_str())
547 .collect::<BTreeSet<_>>()
548 .len();
549 let model_count = selected_routes
550 .iter()
551 .map(|route| route.model.as_str())
552 .collect::<BTreeSet<_>>()
553 .len();
554 let matrix = DispatchAuditToolProbeMatrix {
555 provider_count,
556 model_count,
557 provider_model_count: selected_routes.len(),
558 route_count: selected_routes.len(),
559 case_count: case_selection.included.len(),
560 mode_count: modes.len(),
561 live_request_profile_count: live_request_profiles.len(),
562 request_audit_profile_count: request_audit_profiles.len(),
563 readiness_command_count: readiness_commands.len(),
564 command_count: commands.len(),
565 request_audit_command_count: request_audit_commands.len(),
566 not_applicable_count: not_applicable_commands.len(),
567 };
568 DispatchAuditToolProbePlan {
569 schema_version: DISPATCH_AUDIT_SCHEMA_VERSION,
570 plan_id,
571 catalog_hash_blake3: catalog_hash.to_string(),
572 matrix,
573 readiness_command_count: readiness_commands.len(),
574 command_count: commands.len(),
575 request_audit_command_count: request_audit_commands.len(),
576 route_count: selected_routes.len(),
577 cases: case_selection
578 .included
579 .iter()
580 .map(|case| case.as_str().to_string())
581 .collect(),
582 excluded_cases: case_selection.excluded,
583 not_applicable_commands,
584 live_request_profiles,
585 request_audit_profiles,
586 modes: modes.iter().map(|mode| mode.as_str().to_string()).collect(),
587 repeat: args.tool_probe_repeat,
588 timeout_secs: args.tool_probe_timeout_secs,
589 output_dir,
590 readiness_commands,
591 commands,
592 request_audit_commands,
593 }
594}
595
596fn live_tool_probe_command(
597 context: &DispatchAuditToolProbeCommandContext<'_>,
598 route: &harn_vm::provider_catalog::CatalogRoutingRoute,
599 case_name: &str,
600 request_profile: &str,
601 mode_name: &str,
602 mode: harn_vm::llm::tool_conformance::ToolProbeMode,
603) -> DispatchAuditToolProbeCommand {
604 let id = stable_id(&[
605 "tool_probe",
606 context.catalog_hash,
607 &route.provider,
608 &route.model,
609 case_name,
610 request_profile,
611 mode_name,
612 &context.repeat.to_string(),
613 &context.timeout_secs.to_string(),
614 ]);
615 let mut argv = vec![
616 "harn".to_string(),
617 "provider".to_string(),
618 "tool-probe".to_string(),
619 route.provider.clone(),
620 "--model".to_string(),
621 route.model.clone(),
622 "--case".to_string(),
623 case_name.to_string(),
624 "--request-profile".to_string(),
625 request_profile.to_string(),
626 "--mode".to_string(),
627 mode_cli_value(mode).to_string(),
628 "--repeat".to_string(),
629 context.repeat.to_string(),
630 "--timeout-secs".to_string(),
631 context.timeout_secs.to_string(),
632 "--json".to_string(),
633 ];
634 argv.shrink_to_fit();
635 let (structured_output, structured_output_mode) = route_structured_output_contract(route);
636 DispatchAuditToolProbeCommand {
637 output_path: tool_probe_output_path(
638 context.output_dir,
639 &id,
640 &route.provider,
641 &route.model,
642 case_name,
643 request_profile,
644 mode_name,
645 ),
646 id,
647 route: route_key(route),
648 provider: route.provider.clone(),
649 model: route.model.clone(),
650 structured_output,
651 structured_output_mode,
652 secret_envs: secret_envs_for_route(route, context.secret_envs_by_provider),
653 case: case_name.to_string(),
654 request_profile: request_profile.to_string(),
655 mode: mode_name.to_string(),
656 repeat: context.repeat,
657 timeout_secs: context.timeout_secs,
658 argv,
659 }
660}
661
662fn request_audit_tool_probe_command(
663 context: &DispatchAuditToolProbeCommandContext<'_>,
664 route: &harn_vm::provider_catalog::CatalogRoutingRoute,
665 case_name: &str,
666 request_profile: &str,
667 mode_name: &str,
668 mode: harn_vm::llm::tool_conformance::ToolProbeMode,
669) -> DispatchAuditToolProbeRequestAuditCommand {
670 let id = stable_id(&[
671 "tool_probe_request_audit",
672 context.catalog_hash,
673 &route.provider,
674 &route.model,
675 case_name,
676 request_profile,
677 mode_name,
678 ]);
679 let mut argv = vec![
680 "harn".to_string(),
681 "provider".to_string(),
682 "tool-probe".to_string(),
683 route.provider.clone(),
684 "--model".to_string(),
685 route.model.clone(),
686 "--case".to_string(),
687 case_name.to_string(),
688 "--request-profile".to_string(),
689 request_profile.to_string(),
690 "--mode".to_string(),
691 mode_cli_value(mode).to_string(),
692 "--dry-run-request".to_string(),
693 "--json".to_string(),
694 ];
695 argv.shrink_to_fit();
696 let (structured_output, structured_output_mode) = route_structured_output_contract(route);
697 DispatchAuditToolProbeRequestAuditCommand {
698 output_path: tool_probe_output_path(
699 context.output_dir,
700 &id,
701 &route.provider,
702 &route.model,
703 case_name,
704 request_profile,
705 mode_name,
706 ),
707 id,
708 route: route_key(route),
709 provider: route.provider.clone(),
710 model: route.model.clone(),
711 structured_output,
712 structured_output_mode,
713 secret_envs: secret_envs_for_route(route, context.secret_envs_by_provider),
714 case: case_name.to_string(),
715 request_profile: request_profile.to_string(),
716 mode: mode_name.to_string(),
717 argv,
718 }
719}
720
721fn tool_probe_readiness_commands(
722 catalog_hash: &str,
723 output_dir: &str,
724 selected_routes: &[harn_vm::provider_catalog::CatalogRoutingRoute],
725 secret_envs_by_provider: &BTreeMap<String, Vec<String>>,
726) -> Vec<DispatchAuditReadinessCommand> {
727 selected_routes
728 .iter()
729 .map(|route| {
730 let id = stable_id(&[
731 "provider_ready",
732 catalog_hash,
733 &route.provider,
734 &route.model,
735 ]);
736 let (structured_output, structured_output_mode) =
737 route_structured_output_contract(route);
738 DispatchAuditReadinessCommand {
739 output_path: readiness_output_path(output_dir, &id, &route.provider, &route.model),
740 id,
741 route: route_key(route),
742 provider: route.provider.clone(),
743 model: route.model.clone(),
744 structured_output,
745 structured_output_mode,
746 secret_envs: secret_envs_for_route(route, secret_envs_by_provider),
747 argv: vec![
748 "harn".to_string(),
749 "provider".to_string(),
750 "ready".to_string(),
751 route.provider.clone(),
752 "--model".to_string(),
753 route.model.clone(),
754 "--json".to_string(),
755 ],
756 }
757 })
758 .collect()
759}
760
761fn route_structured_output_contract(
762 route: &harn_vm::provider_catalog::CatalogRoutingRoute,
763) -> (Option<String>, String) {
764 let caps = harn_vm::llm::capabilities::lookup(&route.provider, &route.model);
765 (caps.structured_output, caps.structured_output_mode)
766}
767
768fn catalog_secret_envs_by_provider(
769 providers: &[harn_vm::provider_catalog::CatalogProvider],
770) -> BTreeMap<String, Vec<String>> {
771 providers
772 .iter()
773 .map(|provider| (provider.id.clone(), provider.auth.env.clone()))
774 .collect()
775}
776
777fn secret_envs_for_route(
778 route: &harn_vm::provider_catalog::CatalogRoutingRoute,
779 secret_envs_by_provider: &BTreeMap<String, Vec<String>>,
780) -> Vec<String> {
781 secret_envs_by_provider
782 .get(&route.provider)
783 .cloned()
784 .filter(|envs| !envs.is_empty())
785 .or_else(|| route.secret_env.clone().map(|env| vec![env]))
786 .unwrap_or_default()
787}
788
789struct ToolProbePlanCases {
790 included: Vec<harn_vm::llm::tool_conformance::ToolProbeCase>,
791 excluded: Vec<String>,
792}
793
794fn tool_probe_plan_cases(cases: &[ProviderToolProbeCaseArg]) -> ToolProbePlanCases {
795 if cases.is_empty() {
796 use harn_vm::llm::tool_conformance::ToolProbeCase;
797 return ToolProbePlanCases {
798 included: vec![
799 ToolProbeCase::SingleToolCall,
800 ToolProbeCase::ParallelToolCalls,
801 ToolProbeCase::LargeStringArgument,
802 ToolProbeCase::ToolResultFollowup,
803 ToolProbeCase::NoToolAnswerOrRefusal,
804 ToolProbeCase::UnavailableToolRepair,
805 ToolProbeCase::DoneSentinel,
806 ],
807 excluded: vec!["signed_thinking_tool_result_followup".to_string()],
808 };
809 }
810 let mut out = Vec::new();
811 for case in cases {
812 let probe_case = case.tool_probe_case();
813 if !out.contains(&probe_case) {
814 out.push(probe_case);
815 }
816 }
817 ToolProbePlanCases {
818 included: out,
819 excluded: Vec::new(),
820 }
821}
822
823fn mode_cli_value(mode: harn_vm::llm::tool_conformance::ToolProbeMode) -> &'static str {
824 match mode {
825 harn_vm::llm::tool_conformance::ToolProbeMode::NonStreaming => "non-streaming",
826 harn_vm::llm::tool_conformance::ToolProbeMode::Streaming => "streaming",
827 }
828}
829
830fn route_key(route: &harn_vm::provider_catalog::CatalogRoutingRoute) -> String {
831 format!("{}:{}", route.provider, route.model)
832}
833
834fn default_tool_probe_output_dir(catalog_hash: &str) -> String {
835 format!(
836 ".harn-runs/provider-live-probes/{}",
837 catalog_hash_slug(catalog_hash)
838 )
839}
840
841fn catalog_hash_slug(catalog_hash: &str) -> String {
842 catalog_hash
843 .strip_prefix("blake3:")
844 .unwrap_or(catalog_hash)
845 .chars()
846 .take(16)
847 .collect()
848}
849
850fn tool_probe_output_path(
851 output_dir: &str,
852 command_id: &str,
853 provider: &str,
854 model: &str,
855 case_name: &str,
856 request_profile: &str,
857 mode_name: &str,
858) -> String {
859 format!(
860 "{}/{}-{}-{}-{}-{}-{}.json",
861 output_dir.trim_end_matches('/'),
862 path_slug(&command_id.chars().take(12).collect::<String>()),
863 path_slug(provider),
864 path_slug(model),
865 path_slug(case_name),
866 path_slug(request_profile),
867 path_slug(mode_name),
868 )
869}
870
871fn readiness_output_path(
872 output_dir: &str,
873 command_id: &str,
874 provider: &str,
875 model: &str,
876) -> String {
877 format!(
878 "{}/{}-{}-{}-readiness.json",
879 output_dir.trim_end_matches('/'),
880 path_slug(&command_id.chars().take(12).collect::<String>()),
881 path_slug(provider),
882 path_slug(model),
883 )
884}
885
886fn path_slug(value: &str) -> String {
887 value
888 .chars()
889 .map(|ch| {
890 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
891 ch
892 } else {
893 '_'
894 }
895 })
896 .collect()
897}
898
899fn stable_id(parts: &[&str]) -> String {
900 let joined = parts.join("\0");
901 let hash = blake3::hash(joined.as_bytes());
902 format!("{}", hash.to_hex())
903}
904
905fn selected_routes(
906 args: &ProviderDispatchAuditArgs,
907 routes: &[harn_vm::provider_catalog::CatalogRoutingRoute],
908) -> Vec<harn_vm::provider_catalog::CatalogRoutingRoute> {
909 let provider_filter: BTreeSet<&str> = args.providers.iter().map(String::as_str).collect();
910 let model_filter: BTreeSet<&str> = args.models.iter().map(String::as_str).collect();
911 let capability_filter: BTreeSet<&str> = args.capabilities.iter().map(String::as_str).collect();
912 let route_filter: BTreeSet<(&str, &str)> = args
913 .routes
914 .iter()
915 .filter_map(|route| parse_route_filter(route))
916 .collect();
917 let route_filter_requested = !args.routes.is_empty();
918 routes
919 .iter()
920 .filter(|route| {
921 provider_filter.is_empty() || provider_filter.contains(route.provider.as_str())
922 })
923 .filter(|route| model_filter.is_empty() || model_filter.contains(route.model.as_str()))
924 .filter(|route| {
925 capability_filter.is_empty()
926 || capability_filter
927 .iter()
928 .all(|capability| route.capabilities.iter().any(|value| value == capability))
929 })
930 .filter(|route| {
931 !route_filter_requested
932 || route_filter.contains(&(route.provider.as_str(), route.model.as_str()))
933 })
934 .cloned()
935 .collect()
936}
937
938fn catalog_provenance(
939 artifact: &harn_vm::provider_catalog::ProviderCatalogArtifact,
940) -> DispatchAuditCatalogProvenance {
941 let catalog_json =
942 serde_json::to_vec(artifact).expect("provider catalog serializes for audit provenance");
943 DispatchAuditCatalogProvenance {
944 hash_blake3: format!("blake3:{}", blake3::hash(&catalog_json)),
945 provider_count: artifact.providers.len(),
946 model_count: artifact.models.len(),
947 routing_route_count: artifact.routing_routes.len(),
948 }
949}
950
951fn route_filter_failures(routes: &[String]) -> Vec<DispatchAuditFailure> {
952 routes
953 .iter()
954 .filter(|route| parse_route_filter(route).is_none())
955 .map(|route| DispatchAuditFailure {
956 code: "invalid_route_filter".to_string(),
957 message: "route filters must use provider:model".to_string(),
958 provider: None,
959 route: Some(route.clone()),
960 filter_kind: Some("route".to_string()),
961 filter_value: Some(route.clone()),
962 })
963 .collect()
964}
965
966fn provider_filter_failures(
967 args: &ProviderDispatchAuditArgs,
968 artifact: &harn_vm::provider_catalog::ProviderCatalogArtifact,
969 unrouted_providers: &[DispatchAuditUnroutedProvider],
970) -> Vec<DispatchAuditFailure> {
971 if args.providers.is_empty() {
972 return Vec::new();
973 }
974 let catalog_providers: BTreeSet<&str> = artifact
975 .providers
976 .iter()
977 .map(|provider| provider.id.as_str())
978 .collect();
979 let unrouted_by_provider: BTreeMap<&str, &DispatchAuditUnroutedProvider> = unrouted_providers
980 .iter()
981 .map(|provider| (provider.provider.as_str(), provider))
982 .collect();
983 let mut failures = Vec::new();
984 for provider in &args.providers {
985 if !catalog_providers.contains(provider.as_str()) {
986 failures.push(DispatchAuditFailure {
987 code: "missing_provider_filter".to_string(),
988 message: "provider filter did not match any catalog provider".to_string(),
989 provider: Some(provider.clone()),
990 route: None,
991 filter_kind: Some("provider".to_string()),
992 filter_value: Some(provider.clone()),
993 });
994 continue;
995 }
996 if let Some(unrouted) = unrouted_by_provider.get(provider.as_str()) {
997 failures.push(DispatchAuditFailure {
998 code: "provider_has_no_routing_routes".to_string(),
999 message: format!(
1000 "provider has zero catalog routing routes: {}",
1001 unrouted.reason
1002 ),
1003 provider: Some(provider.clone()),
1004 route: None,
1005 filter_kind: Some("provider".to_string()),
1006 filter_value: Some(provider.clone()),
1007 });
1008 }
1009 }
1010 failures
1011}
1012
1013fn model_filter_failures(
1014 args: &ProviderDispatchAuditArgs,
1015 artifact: &harn_vm::provider_catalog::ProviderCatalogArtifact,
1016 selected_routes: &[harn_vm::provider_catalog::CatalogRoutingRoute],
1017) -> Vec<DispatchAuditFailure> {
1018 if args.models.is_empty() {
1019 return Vec::new();
1020 }
1021 let catalog_route_models: BTreeSet<&str> = artifact
1022 .routing_routes
1023 .iter()
1024 .map(|route| route.model.as_str())
1025 .collect();
1026 let selected_models: BTreeSet<&str> = selected_routes
1027 .iter()
1028 .map(|route| route.model.as_str())
1029 .collect();
1030 args.models
1031 .iter()
1032 .filter(|model| {
1033 !catalog_route_models.contains(model.as_str())
1034 || !selected_models.contains(model.as_str())
1035 })
1036 .map(|model| DispatchAuditFailure {
1037 code: "missing_model_filter".to_string(),
1038 message: "model filter did not match any selected catalog routing route".to_string(),
1039 provider: None,
1040 route: None,
1041 filter_kind: Some("model".to_string()),
1042 filter_value: Some(model.clone()),
1043 })
1044 .collect()
1045}
1046
1047fn capability_filter_failures(
1048 args: &ProviderDispatchAuditArgs,
1049 selected_routes: &[harn_vm::provider_catalog::CatalogRoutingRoute],
1050) -> Vec<DispatchAuditFailure> {
1051 if args.capabilities.is_empty() {
1052 return Vec::new();
1053 }
1054 let selected_capabilities: BTreeSet<&str> = selected_routes
1055 .iter()
1056 .flat_map(|route| route.capabilities.iter().map(String::as_str))
1057 .collect();
1058 args.capabilities
1059 .iter()
1060 .filter(|capability| !selected_capabilities.contains(capability.as_str()))
1061 .map(|capability| DispatchAuditFailure {
1062 code: "missing_capability_filter".to_string(),
1063 message: "capability filter did not match any selected catalog routing route"
1064 .to_string(),
1065 provider: None,
1066 route: None,
1067 filter_kind: Some("capability".to_string()),
1068 filter_value: Some(capability.clone()),
1069 })
1070 .collect()
1071}
1072
1073fn missing_route_filter_failures(
1074 args: &ProviderDispatchAuditArgs,
1075 selected_routes: &[harn_vm::provider_catalog::CatalogRoutingRoute],
1076) -> Vec<DispatchAuditFailure> {
1077 if args.routes.is_empty() {
1078 return Vec::new();
1079 }
1080 let selected: BTreeSet<String> = selected_routes.iter().map(route_key).collect();
1081 args.routes
1082 .iter()
1083 .filter(|route| parse_route_filter(route).is_some())
1084 .filter(|route| !selected.contains(route.as_str()))
1085 .map(|route| DispatchAuditFailure {
1086 code: "missing_route_filter".to_string(),
1087 message: "route filter did not match any catalog routing route".to_string(),
1088 provider: None,
1089 route: Some(route.clone()),
1090 filter_kind: Some("route".to_string()),
1091 filter_value: Some(route.clone()),
1092 })
1093 .collect()
1094}
1095
1096fn unrouted_providers(
1097 artifact: &harn_vm::provider_catalog::ProviderCatalogArtifact,
1098) -> Vec<DispatchAuditUnroutedProvider> {
1099 let route_counts =
1100 artifact
1101 .routing_routes
1102 .iter()
1103 .fold(BTreeMap::<&str, usize>::new(), |mut counts, route| {
1104 *counts.entry(route.provider.as_str()).or_insert(0) += 1;
1105 counts
1106 });
1107 let (model_counts, active_model_counts) = artifact.models.iter().fold(
1108 (
1109 BTreeMap::<&str, usize>::new(),
1110 BTreeMap::<&str, usize>::new(),
1111 ),
1112 |(mut model_counts, mut active_counts), model| {
1113 *model_counts.entry(model.provider.as_str()).or_insert(0) += 1;
1114 if model.deprecation.status == harn_vm::provider_catalog::DeprecationStatus::Active {
1115 *active_counts.entry(model.provider.as_str()).or_insert(0) += 1;
1116 }
1117 (model_counts, active_counts)
1118 },
1119 );
1120 artifact
1121 .providers
1122 .iter()
1123 .filter_map(|provider| {
1124 let route_count = route_counts
1125 .get(provider.id.as_str())
1126 .copied()
1127 .unwrap_or_default();
1128 if route_count > 0 {
1129 return None;
1130 }
1131 let model_count = model_counts
1132 .get(provider.id.as_str())
1133 .copied()
1134 .unwrap_or_default();
1135 let active_model_count = active_model_counts
1136 .get(provider.id.as_str())
1137 .copied()
1138 .unwrap_or_default();
1139 let reason = if model_count == 0 {
1140 "catalog_provider_has_no_models"
1141 } else if active_model_count == 0 {
1142 "catalog_provider_has_no_active_models"
1143 } else {
1144 "catalog_provider_has_no_routing_routes"
1145 };
1146 Some(DispatchAuditUnroutedProvider {
1147 provider: provider.id.clone(),
1148 model_count,
1149 active_model_count,
1150 route_count,
1151 reason: reason.to_string(),
1152 })
1153 })
1154 .collect()
1155}
1156
1157fn parse_route_filter(route: &str) -> Option<(&str, &str)> {
1158 let (provider, model) = route.split_once(':')?;
1159 if provider.is_empty() || model.is_empty() {
1160 return None;
1161 }
1162 Some((provider, model))
1163}
1164
1165fn dispatch_audit_variants(
1166 variants: &[ProviderDispatchAuditVariantArg],
1167) -> Vec<ProviderDispatchAuditVariantArg> {
1168 if variants.is_empty() {
1169 vec![
1170 ProviderDispatchAuditVariantArg::Default,
1171 ProviderDispatchAuditVariantArg::Thinking,
1172 ProviderDispatchAuditVariantArg::Native,
1173 ProviderDispatchAuditVariantArg::Text,
1174 ProviderDispatchAuditVariantArg::Json,
1175 ]
1176 } else {
1177 let mut out = Vec::new();
1178 for variant in variants {
1179 if !out.contains(variant) {
1180 out.push(*variant);
1181 }
1182 }
1183 out
1184 }
1185}
1186
1187fn print_human_explanation(report: &DispatchExplanation) {
1188 println!("dispatch-explain {}:{}", report.provider, report.model);
1189 println!(" wire_format: {}", report.wire_format);
1190 println!(" message_wire: {}", report.message_wire_format);
1191 if let Some(family) = &report.live_endpoint_family {
1192 println!(" live_endpoint: {family}");
1193 }
1194 println!(" tool_wire: {}", report.native_tool_wire_format);
1195 println!(" base_url_host: {}", report.base_url_host);
1196 println!(" tool_format: {}", report.tool_format);
1197 println!(" native_tools: {}", report.native_tools);
1198 println!(
1199 " thinking: advertised={advertises_thinking}{}",
1200 if report.requested_thinking {
1201 " (requested)"
1202 } else {
1203 ""
1204 },
1205 advertises_thinking = report.advertises_thinking
1206 );
1207 if let Some(note) = &report.thinking_note {
1208 println!(" NOTE: {note}");
1209 }
1210}
1211
1212fn print_json_or_exit(value: &impl Serialize, label: &str) {
1213 match serde_json::to_string_pretty(value) {
1214 Ok(json) => println!("{json}"),
1215 Err(error) => {
1216 eprintln!("internal error: failed to render {label}: {error}");
1217 process::exit(1);
1218 }
1219 }
1220}
1221
1222fn default_base_url_for(wire_format: &str) -> String {
1223 match wire_format {
1224 "anthropic_native" => "https://api.anthropic.com/v1".to_string(),
1225 "gemini" => "https://generativelanguage.googleapis.com/v1beta".to_string(),
1226 "ollama" => "http://localhost:11434".to_string(),
1227 _ => "https://api.openai.com/v1".to_string(),
1228 }
1229}
1230
1231fn host_of(base_url: &str) -> String {
1232 base_url
1233 .split("://")
1234 .nth(1)
1235 .and_then(|rest| rest.split('/').next())
1236 .map(str::to_string)
1237 .unwrap_or_else(|| base_url.to_string())
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242 use crate::cli::ProviderToolProbeModeArg;
1243
1244 use super::*;
1245
1246 #[test]
1247 fn dispatch_audit_tool_probe_plan_separates_live_and_request_audit_commands() {
1248 let artifact = harn_vm::provider_catalog::artifact();
1249 let route = artifact
1250 .routing_routes
1251 .first()
1252 .expect("provider catalog should contain at least one routing route");
1253 let args = ProviderDispatchAuditArgs {
1254 providers: Vec::new(),
1255 models: Vec::new(),
1256 routes: vec![route_key(route)],
1257 capabilities: Vec::new(),
1258 variants: Vec::new(),
1259 include_tool_probe_plan: true,
1260 tool_probe_cases: vec![ProviderToolProbeCaseArg::SingleToolCall],
1261 tool_probe_mode: ProviderToolProbeModeArg::NonStreaming,
1262 tool_probe_repeat: 3,
1263 tool_probe_timeout_secs: 45,
1264 tool_probe_output_dir: Some(".harn-runs/provider-live-probes/test".to_string()),
1265 json: true,
1266 };
1267
1268 let report = audit(&args);
1269
1270 assert_eq!(report.schema_version, 4);
1271 assert_eq!(report.fail_count, 0, "{:?}", report.failures);
1272 let plan = report.tool_probe_plan.expect("tool probe plan emitted");
1273 assert_eq!(plan.schema_version, 4);
1274 assert_eq!(plan.live_request_profiles, vec!["catalog_default"]);
1275 assert_eq!(plan.request_audit_profiles, vec!["parameter_edges"]);
1276 assert_eq!(plan.command_count, 1);
1277 assert_eq!(plan.request_audit_command_count, 1);
1278 assert_eq!(plan.matrix.command_count, 1);
1279 assert_eq!(plan.matrix.request_audit_command_count, 1);
1280
1281 let live = &plan.commands[0];
1282 assert_eq!(live.request_profile, "catalog_default");
1283 assert_eq!(live.repeat, 3);
1284 assert_eq!(live.timeout_secs, 45);
1285 assert!(live.argv.contains(&"--repeat".to_string()));
1286 assert!(!live.argv.contains(&"--dry-run-request".to_string()));
1287
1288 let request_audit = &plan.request_audit_commands[0];
1289 assert_eq!(request_audit.request_profile, "parameter_edges");
1290 assert!(request_audit
1291 .argv
1292 .contains(&"--dry-run-request".to_string()));
1293 assert!(request_audit.argv.contains(&"--json".to_string()));
1294 assert!(!request_audit.argv.contains(&"--repeat".to_string()));
1295 assert!(!request_audit.argv.contains(&"--timeout-secs".to_string()));
1296 }
1297}