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    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    // A model advertises thinking when the capability row lists any thinking
313    // mode; requesting thinking against a model that advertises none is a
314    // footgun (the operator asked for reasoning the route will silently drop).
315    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}