Skip to main content

apr_cli/
dispatch_run.rs

1
2/// Dispatch `apr run` — extracted to reduce cognitive complexity of `execute_command`
3#[allow(clippy::too_many_arguments)]
4fn dispatch_run(
5    source: &str,
6    positional_prompt: Option<&String>,
7    input: Option<&Path>,
8    prompt: Option<&String>,
9    max_tokens: usize,
10    stream: bool,
11    language: Option<&str>,
12    task: Option<&str>,
13    format: &str,
14    no_gpu: bool,
15    offline: bool,
16    benchmark: bool,
17    verbose: bool,
18    trace: bool,
19    trace_payload: bool,
20    trace_steps: Option<&[String]>,
21    trace_verbose: bool,
22    trace_output: Option<PathBuf>,
23    trace_level: &str,
24    profile: bool,
25    chat: bool,
26    // PMAT-496: Sampling parameters
27    temperature: f32,
28    top_k: usize,
29    top_p: Option<f32>,
30    seed: u64,
31    repeat_penalty: f32,
32    repeat_last_n: usize,
33    split_prompt: bool,
34) -> Result<(), CliError> {
35    let effective_trace = trace || trace_payload;
36    let effective_trace_level = if trace_payload {
37        "payload"
38    } else {
39        trace_level
40    };
41    let merged_prompt = prompt.or(positional_prompt).cloned();
42    // GH-638: Auto-detect chat template from model name when --chat not explicit.
43    // Instruct/Chat models (Qwen-Instruct, LLaMA-Instruct, Mistral-Instruct, etc.)
44    // need ChatML wrapping for correct output. Without it, the model ignores the
45    // prompt structure and produces garbled responses.
46    let use_chat = chat || {
47        let src_lower = source.to_lowercase();
48        merged_prompt.is_some()
49            && (src_lower.contains("instruct") || src_lower.contains("chat"))
50    };
51    let effective_prompt = if use_chat {
52        merged_prompt
53            .as_ref()
54            .map(|p| format!("<|im_start|>user\n{p}<|im_end|>\n<|im_start|>assistant\n"))
55    } else {
56        merged_prompt
57    };
58
59    run::run(
60        source,
61        input,
62        effective_prompt.as_deref(),
63        max_tokens,
64        stream,
65        language,
66        task,
67        format,
68        no_gpu,
69        offline,
70        benchmark,
71        verbose,
72        effective_trace,
73        trace_steps,
74        trace_verbose,
75        trace_output,
76        effective_trace_level,
77        profile,
78        temperature,
79        top_k,
80        top_p,
81        seed,
82        repeat_penalty,
83        repeat_last_n,
84        split_prompt,
85    )
86}
87
88/// Build server config and launch serve.
89#[allow(clippy::too_many_arguments)]
90fn dispatch_serve(
91    file: &Path,
92    port: u16,
93    host: &str,
94    no_cors: bool,
95    no_metrics: bool,
96    no_gpu: bool,
97    gpu: bool,
98    batch: bool,
99    trace: bool,
100    trace_level: &str,
101    profile: bool,
102    verbose: bool,
103    backend: &Option<String>,
104    otlp_endpoint: &Option<String>,
105    context_length: usize,
106    no_fp8_cache: bool,
107    ollama_compat: bool,
108) -> Result<(), CliError> {
109    if let Some(ref endpoint) = otlp_endpoint {
110        eprintln!("OTLP tracing enabled → {endpoint}");
111        eprintln!("  Spans exported as W3C Trace Context (PMAT-485)");
112    }
113    let config = serve::ServerConfig {
114        port: if ollama_compat && port == 8080 { 11434 } else { port },
115        host: host.to_owned(),
116        cors: !no_cors,
117        metrics: !no_metrics,
118        no_gpu,
119        gpu,
120        batch,
121        trace,
122        trace_level: trace_level.to_owned(),
123        profile,
124        verbose,
125        backend: backend.clone(),
126        otlp_endpoint: otlp_endpoint.clone(),
127        context_length,
128        no_fp8_cache,
129        ollama_compat,
130        ..Default::default()
131    };
132    serve::run(file, &config)
133}
134
135/// Route `apr serve` subcommands: plan or run.
136fn dispatch_serve_command(command: &ServeCommands, cli: &Cli) -> Result<(), CliError> {
137    match command {
138        ServeCommands::Plan {
139            model,
140            gpu,
141            batch_size,
142            seq_len,
143            format,
144            quant,
145        } => {
146            // GH-630: Thread cli.json through to serve plan
147            let effective_format = if cli.json { "json" } else { format.as_str() };
148            commands::serve_plan::run_serve_plan(
149                model, *gpu, *batch_size, *seq_len, effective_format, quant.as_deref(),
150            )
151        }
152        ServeCommands::Run {
153            file,
154            port,
155            host,
156            no_cors,
157            no_metrics,
158            no_gpu,
159            gpu,
160            batch,
161            trace,
162            trace_level,
163            profile,
164            backend,
165            otlp_endpoint,
166            context_length,
167            no_fp8_cache,
168            ollama_compat,
169        } => crate::error::resolve_model_path(file).and_then(|r| {
170            dispatch_serve(
171                &r,
172                *port,
173                host,
174                *no_cors,
175                *no_metrics,
176                *no_gpu,
177                *gpu,
178                *batch,
179                *trace,
180                trace_level,
181                *profile,
182                cli.verbose,
183                backend,
184                otlp_endpoint,
185                *context_length,
186                *no_fp8_cache,
187                *ollama_compat,
188            )
189        }),
190    }
191}
192
193/// Parse hex offset and run hex inspection.
194#[allow(clippy::too_many_arguments)]
195fn dispatch_hex(
196    file: &Path,
197    tensor: Option<&str>,
198    limit: usize,
199    stats: bool,
200    list: bool,
201    json: bool,
202    header: bool,
203    blocks: bool,
204    distribution: bool,
205    contract: bool,
206    entropy: bool,
207    raw: bool,
208    offset: &str,
209    width: usize,
210    slice: Option<&str>,
211) -> Result<(), CliError> {
212    let parsed_offset = hex::parse_hex_offset(offset).map_err(CliError::InvalidFormat)?;
213    hex::run(&hex::HexOptions {
214        file: file.to_path_buf(),
215        tensor: tensor.map(String::from),
216        limit,
217        stats,
218        list,
219        json,
220        header,
221        blocks,
222        distribution,
223        contract,
224        entropy,
225        raw,
226        offset: parsed_offset,
227        width,
228        slice: slice.map(String::from),
229    })
230}
231
232/// Dispatch a rosetta subcommand.
233fn dispatch_rosetta(action: &RosettaCommands, global_json: bool) -> Result<(), CliError> {
234    match action {
235        RosettaCommands::Inspect {
236            file,
237            hexdump,
238            json,
239        } => rosetta::run_inspect(file, *hexdump, *json || global_json),
240        RosettaCommands::Convert {
241            source,
242            target,
243            quantize,
244            verify,
245            json,
246            tokenizer,
247        } => rosetta::run_convert(
248            source,
249            target,
250            quantize.as_deref(),
251            *verify,
252            *json || global_json,
253            tokenizer.as_deref(),
254        ),
255        RosettaCommands::Chain {
256            source,
257            formats,
258            work_dir,
259            json,
260        } => rosetta::run_chain(source, formats, work_dir, *json || global_json),
261        RosettaCommands::Verify {
262            source,
263            intermediate,
264            tolerance,
265            json,
266        } => rosetta::run_verify(source, intermediate, *tolerance, *json || global_json),
267        RosettaCommands::CompareInference {
268            model_a,
269            model_b,
270            prompt,
271            max_tokens,
272            temperature,
273            tolerance,
274            json,
275        } => rosetta::run_compare_inference(
276            model_a,
277            model_b,
278            prompt,
279            *max_tokens,
280            *temperature,
281            *tolerance,
282            *json || global_json,
283        ),
284        RosettaCommands::DiffTensors {
285            model_a,
286            model_b,
287            mismatches_only,
288            show_values,
289            filter,
290            json,
291        } => rosetta::run_diff_tensors(
292            model_a,
293            model_b,
294            *mismatches_only,
295            *show_values,
296            filter.as_deref(),
297            *json || global_json,
298        ),
299        RosettaCommands::Fingerprint {
300            model,
301            model_b,
302            output,
303            filter,
304            verbose,
305            json,
306        } => rosetta::run_fingerprint(
307            model,
308            model_b.as_ref().map(std::path::PathBuf::as_path),
309            output.as_ref().map(std::path::PathBuf::as_path),
310            filter.as_deref(),
311            *verbose,
312            *json || global_json,
313        ),
314        RosettaCommands::ValidateStats {
315            model,
316            reference,
317            fingerprints,
318            threshold,
319            strict,
320            json,
321        } => rosetta::run_validate_stats(
322            model,
323            reference.as_ref().map(std::path::PathBuf::as_path),
324            fingerprints.as_ref().map(std::path::PathBuf::as_path),
325            *threshold,
326            *strict,
327            *json || global_json,
328        ),
329    }
330}
331
332/// Execute the CLI command and return the result.
333pub fn execute_command(cli: &Cli) -> Result<(), CliError> {
334    contract_pre_contract_gate_enforcement!();
335    // PMAT-237: Contract gate — refuse to operate on corrupt models
336    if !cli.skip_contract {
337        let paths = extract_model_paths(&cli.command);
338        validate_model_contract(&paths)?;
339    }
340
341    dispatch_core_command(cli).unwrap_or_else(|| dispatch_extended_command(cli))
342}