Skip to main content

harn_cli/commands/
dispatch_explain.rs

1//! `harn provider dispatch-explain <provider> <model>` — deterministically
2//! explain how a (provider, model) pair would dispatch, from the capability
3//! registry alone. No network, no LLM call.
4//!
5//! This is the static counterpart to the runtime `resolved_dispatch` transcript
6//! record: instead of asking "what did this call dispatch", it answers "what
7//! WOULD this pair dispatch" — so anyone can confirm "does anthropic
8//! claude-sonnet route native?" without running an eval and grepping a
9//! transcript.
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::process;
13
14use serde::Serialize;
15
16use crate::cli::{
17    ProviderDispatchAuditArgs, ProviderDispatchAuditVariantArg, ProviderDispatchExplainArgs,
18    ProviderToolProbeCaseArg,
19};
20
21/// Schema version stamped on every `provider dispatch-audit` envelope (the
22/// report and its embedded tool-probe plan). Re-exported from the crate root as
23/// the single source of truth so integration tests reference it instead of
24/// duplicating the number — the duplication that let this rot silently while the
25/// slow-E2E tier was unwatched. The in-crate `audit` unit tests still pin the
26/// literal value, so an intentional bump remains a deliberate, reviewed change.
27pub 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    /// Which synchronous endpoint family the route dispatches to, when its
36    /// dialect serves more than one. `None` for every single-endpoint dialect.
37    #[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    // A model advertises thinking when the capability row lists any thinking
320    // mode; requesting thinking against a model that advertises none is a
321    // footgun (the operator asked for reasoning the route will silently drop).
322    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}