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