use std::path::PathBuf;
use clap::{Args, ValueEnum};
use rapid_structure_rs::{EngineConfig, ProviderPreference};
use super::limits::{
DEFAULT_JOB_TTL_SECS, DEFAULT_MAX_BODY_MB, DEFAULT_MAX_CONSECUTIVE_FORMULA,
DEFAULT_MAX_CONSECUTIVE_TEXT, DEFAULT_MAX_DOWNLOAD_MB, DEFAULT_MAX_EVAL_CASES,
DEFAULT_MAX_EXPORT_MB, DEFAULT_MAX_QUEUE_FORMULA, DEFAULT_MAX_QUEUE_TEXT,
DEFAULT_MAX_RESULT_MB, DEFAULT_MAX_RETAINED, DEFAULT_MAX_RETAINED_MB, DEFAULT_MAX_TOMBSTONES,
DEFAULT_PORT, RawServeLimits,
};
use super::state::StartupConfigError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ProviderChoice {
Cpu,
Directml,
Cuda,
}
impl ProviderChoice {
pub fn preference(self) -> ProviderPreference {
match self {
Self::Cpu => ProviderPreference::Cpu,
Self::Directml => ProviderPreference::DirectMl { device_id: 0 },
Self::Cuda => ProviderPreference::Cuda { device_id: 0 },
}
}
}
#[derive(Debug, Clone, Args)]
pub struct ServeArgs {
#[arg(long, default_value_t = DEFAULT_PORT, value_name = "PORT")]
pub port: u16,
#[arg(long = "model-dir", value_name = "DIR")]
pub model_dir: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
pub config: Option<PathBuf>,
#[arg(long, value_name = "PROV")]
pub provider: Option<ProviderChoice>,
#[arg(long = "allow-provider-fallback")]
pub allow_provider_fallback: bool,
#[arg(long = "max-side", value_name = "N")]
pub max_side: Option<usize>,
#[arg(long = "allow-download")]
pub allow_download: bool,
#[arg(long = "allow-download-host", value_name = "HOST")]
pub allow_download_host: Vec<String>,
#[arg(long)]
pub open: bool,
#[arg(long = "max-body-mb", default_value_t = DEFAULT_MAX_BODY_MB, value_name = "N")]
pub max_body_mb: u64,
#[arg(long = "max-result-mb", default_value_t = DEFAULT_MAX_RESULT_MB, value_name = "N")]
pub max_result_mb: u64,
#[arg(long = "max-export-mb", default_value_t = DEFAULT_MAX_EXPORT_MB, value_name = "N")]
pub max_export_mb: u64,
#[arg(long = "max-download-mb", default_value_t = DEFAULT_MAX_DOWNLOAD_MB, value_name = "N")]
pub max_download_mb: u64,
#[arg(long = "max-queue-text", default_value_t = DEFAULT_MAX_QUEUE_TEXT, value_name = "N")]
pub max_queue_text: usize,
#[arg(long = "max-queue-formula", default_value_t = DEFAULT_MAX_QUEUE_FORMULA, value_name = "N")]
pub max_queue_formula: usize,
#[arg(
long = "max-consecutive-text",
default_value_t = DEFAULT_MAX_CONSECUTIVE_TEXT,
value_name = "N"
)]
pub max_consecutive_text: usize,
#[arg(
long = "max-consecutive-formula",
default_value_t = DEFAULT_MAX_CONSECUTIVE_FORMULA,
value_name = "N"
)]
pub max_consecutive_formula: usize,
#[arg(long = "max-retained", default_value_t = DEFAULT_MAX_RETAINED, value_name = "N")]
pub max_retained: usize,
#[arg(long = "max-retained-mb", default_value_t = DEFAULT_MAX_RETAINED_MB, value_name = "N")]
pub max_retained_mb: u64,
#[arg(long = "max-tombstones", default_value_t = DEFAULT_MAX_TOMBSTONES, value_name = "N")]
pub max_tombstones: usize,
#[arg(long = "job-ttl-secs", default_value_t = DEFAULT_JOB_TTL_SECS, value_name = "N")]
pub job_ttl_secs: u64,
#[arg(long = "formula-detector", value_name = "ONNX")]
pub formula_detector: Option<PathBuf>,
#[arg(long = "table-model", value_name = "ONNX")]
pub table_model: Option<PathBuf>,
#[arg(long = "max-eval-cases", default_value_t = DEFAULT_MAX_EVAL_CASES, value_name = "N")]
pub max_eval_cases: usize,
#[arg(long = "eval-root", value_name = "DIR")]
pub eval_root: Option<PathBuf>,
#[arg(long = "reverify-models")]
pub reverify_models: bool,
#[arg(long = "log-level", value_name = "off|flow")]
pub log_level: Option<String>,
}
impl ServeArgs {
pub fn raw_limits(&self) -> RawServeLimits {
RawServeLimits {
max_body_mb: self.max_body_mb,
max_result_mb: self.max_result_mb,
max_export_mb: self.max_export_mb,
max_download_mb: self.max_download_mb,
max_queue_text: self.max_queue_text,
max_queue_formula: self.max_queue_formula,
max_consecutive_text: self.max_consecutive_text,
max_consecutive_formula: self.max_consecutive_formula,
max_retained: self.max_retained,
max_retained_mb: self.max_retained_mb,
max_tombstones: self.max_tombstones,
job_ttl_secs: self.job_ttl_secs,
max_eval_cases: self.max_eval_cases,
}
}
pub fn provider_preference(&self) -> Option<ProviderPreference> {
self.provider.map(ProviderChoice::preference)
}
pub fn allow_provider_fallback(&self) -> bool {
self.allow_provider_fallback
}
pub fn log_level(&self) -> Result<super::flowlog::LogLevel, StartupConfigError> {
let env = std::env::var("RAPID_STRUCTURE_SERVE_LOG").ok();
super::flowlog::LogLevel::resolve(self.log_level.as_deref(), env.as_deref())
.map_err(StartupConfigError::LogLevel)
}
pub fn model_dir(&self) -> PathBuf {
self.model_dir
.clone()
.unwrap_or_else(rapid_structure_rs::default_model_store_dir)
}
pub fn engine_config(&self) -> Result<EngineConfig, StartupConfigError> {
match &self.config {
Some(path) => EngineConfig::from_yaml_file(path).map_err(|source| {
StartupConfigError::ConfigFile {
path: path.clone(),
source,
}
}),
None => Ok(EngineConfig::default()),
}
}
pub fn uses_documented_defaults(&self) -> bool {
self.raw_limits() == RawServeLimits::default() && self.provider.is_none()
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use clap::Parser as _;
use rapid_structure_rs::ProviderPreference;
use super::{ProviderChoice, ServeArgs};
use crate::serve::limits::{
DEFAULT_ALLOW_DOWNLOAD, DEFAULT_ALLOW_PROVIDER_FALLBACK, DEFAULT_JOB_TTL_SECS,
DEFAULT_MAX_BODY_MB, DEFAULT_MAX_CONSECUTIVE_FORMULA, DEFAULT_MAX_CONSECUTIVE_TEXT,
DEFAULT_MAX_DOWNLOAD_MB, DEFAULT_MAX_EVAL_CASES, DEFAULT_MAX_EXPORT_MB,
DEFAULT_MAX_QUEUE_FORMULA, DEFAULT_MAX_QUEUE_TEXT, DEFAULT_MAX_RESULT_MB,
DEFAULT_MAX_RETAINED, DEFAULT_MAX_RETAINED_MB, DEFAULT_MAX_TOMBSTONES, DEFAULT_PORT, MIB,
};
const DOCUMENTED_OPTION_NAMES: [&str; 27] = [
"port",
"model-dir",
"config",
"provider",
"allow-provider-fallback",
"max-side",
"allow-download",
"allow-download-host",
"open",
"max-body-mb",
"max-result-mb",
"max-export-mb",
"max-download-mb",
"max-queue-text",
"max-queue-formula",
"max-consecutive-text",
"max-consecutive-formula",
"max-retained",
"max-retained-mb",
"max-tombstones",
"job-ttl-secs",
"formula-detector",
"table-model",
"max-eval-cases",
"eval-root",
"reverify-models",
"log-level",
];
#[derive(clap::Parser)]
#[command(name = "serve")]
struct ServeCli {
#[command(flatten)]
args: ServeArgs,
}
fn try_parse(arguments: &[&str]) -> Result<ServeArgs, clap::Error> {
let mut argv = vec!["serve"];
argv.extend_from_slice(arguments);
ServeCli::try_parse_from(argv).map(|cli| cli.args)
}
fn parse(arguments: &[&str]) -> ServeArgs {
try_parse(arguments).expect("the documented options must parse")
}
#[test]
fn the_option_surface_is_exactly_the_documented_list() {
let command = <ServeArgs as clap::Args>::augment_args(clap::Command::new("serve"));
let names: BTreeSet<String> = command
.get_arguments()
.filter_map(|argument| argument.get_long().map(str::to_string))
.filter(|name| name != "help" && name != "version")
.collect();
let expected: BTreeSet<String> = DOCUMENTED_OPTION_NAMES
.iter()
.map(|name| (*name).to_string())
.collect();
assert_eq!(names, expected, "the serve option surface changed");
assert!(!names.contains("host"), "§7.1 forbids any address option");
assert!(
!names.contains("ocr-workers"),
"§8.2 forbids a worker-count option in M1"
);
}
#[test]
fn neither_host_nor_ocr_workers_can_be_parsed() {
assert!(
try_parse(&["--host", "0.0.0.0"]).is_err(),
"there must be no way to bind another address"
);
assert!(
try_parse(&["--host"]).is_err(),
"--host must not even exist as a flag"
);
assert!(
try_parse(&["--ocr-workers", "4"]).is_err(),
"--ocr-workers must not exist"
);
for option in ["--address", "--bind", "--listen", "--ip"] {
assert!(try_parse(&[option, "0.0.0.0"]).is_err(), "{option}");
}
}
#[test]
fn every_default_matches_the_document() {
let args = parse(&[]);
assert_eq!(args.port, DEFAULT_PORT);
assert_eq!(args.port, 8760);
assert_eq!(args.model_dir, None, "default comes from the library");
assert_eq!(args.config, None);
assert_eq!(
args.provider, None,
"no clap default on purpose: CLI > YAML > builtin (§3)"
);
assert_eq!(args.provider_preference(), None);
assert!(!args.allow_provider_fallback);
assert_eq!(args.max_side, None);
assert!(!args.allow_download);
assert!(args.allow_download_host.is_empty());
assert!(!args.open);
const {
assert!(!DEFAULT_ALLOW_DOWNLOAD);
assert!(!DEFAULT_ALLOW_PROVIDER_FALLBACK);
}
assert_eq!(args.max_body_mb, DEFAULT_MAX_BODY_MB);
assert_eq!(args.max_result_mb, DEFAULT_MAX_RESULT_MB);
assert_eq!(args.max_export_mb, DEFAULT_MAX_EXPORT_MB);
assert_eq!(args.max_download_mb, DEFAULT_MAX_DOWNLOAD_MB);
assert_eq!(args.max_queue_text, DEFAULT_MAX_QUEUE_TEXT);
assert_eq!(args.max_queue_formula, DEFAULT_MAX_QUEUE_FORMULA);
assert_eq!(args.max_consecutive_text, DEFAULT_MAX_CONSECUTIVE_TEXT);
assert_eq!(
args.max_consecutive_formula,
DEFAULT_MAX_CONSECUTIVE_FORMULA
);
assert_eq!(args.max_retained, DEFAULT_MAX_RETAINED);
assert_eq!(args.max_retained_mb, DEFAULT_MAX_RETAINED_MB);
assert_eq!(args.max_tombstones, DEFAULT_MAX_TOMBSTONES);
assert_eq!(args.job_ttl_secs, DEFAULT_JOB_TTL_SECS);
assert_eq!(args.formula_detector, None);
assert_eq!(args.table_model, None);
assert_eq!(args.max_eval_cases, DEFAULT_MAX_EVAL_CASES);
assert_eq!(args.eval_root, None);
assert_eq!(args.max_body_mb, 32);
assert_eq!(args.max_result_mb, 8);
assert_eq!(args.max_export_mb, 32);
assert_eq!(args.max_download_mb, 1024);
assert_eq!(args.max_queue_text, 4);
assert_eq!(args.max_queue_formula, 2);
assert_eq!(args.max_consecutive_text, 4);
assert_eq!(args.max_consecutive_formula, 1);
assert_eq!(args.max_retained, 32);
assert_eq!(args.max_retained_mb, 64);
assert_eq!(args.max_tombstones, 256);
assert_eq!(args.job_ttl_secs, 600);
assert_eq!(args.max_eval_cases, 32);
let limits = args.raw_limits().validate().expect("defaults are valid");
assert_eq!(limits.max_body_bytes, 32 * MIB);
assert_eq!(limits.max_download_bytes, 1024 * MIB);
assert_eq!(limits.job_ttl_ms, 600_000);
assert!(args.uses_documented_defaults());
}
#[test]
fn every_option_can_be_set_from_the_command_line() {
let args = parse(&[
"--port",
"9000",
"--model-dir",
"D:\\models",
"--config",
"cfg.yaml",
"--provider",
"directml",
"--allow-provider-fallback",
"--max-side",
"1600",
"--allow-download",
"--allow-download-host",
"example.org",
"--allow-download-host",
"mirror.example.org",
"--open",
"--max-body-mb",
"16",
"--max-result-mb",
"4",
"--max-export-mb",
"48",
"--max-download-mb",
"2048",
"--max-queue-text",
"8",
"--max-queue-formula",
"3",
"--max-consecutive-text",
"6",
"--max-consecutive-formula",
"2",
"--max-retained",
"64",
"--max-retained-mb",
"128",
"--max-tombstones",
"512",
"--job-ttl-secs",
"1200",
"--formula-detector",
"D:\\models\\pix2text-mfd-1.5.onnx",
"--table-model",
"D:\\models\\slanet-plus.onnx",
"--max-eval-cases",
"8",
"--eval-root",
"D:\\eval-root",
"--reverify-models",
]);
assert_eq!(args.port, 9000);
assert_eq!(
args.model_dir.as_deref(),
Some(std::path::Path::new("D:\\models"))
);
assert_eq!(
args.config.as_deref(),
Some(std::path::Path::new("cfg.yaml"))
);
assert_eq!(args.provider, Some(ProviderChoice::Directml));
assert_eq!(
args.provider_preference(),
Some(ProviderPreference::DirectMl { device_id: 0 })
);
assert!(args.allow_provider_fallback);
assert_eq!(args.max_side, Some(1600));
assert!(args.allow_download);
assert_eq!(
args.allow_download_host,
vec!["example.org".to_string(), "mirror.example.org".to_string()]
);
assert!(args.open);
let limits = args.raw_limits().validate().expect("valid");
assert_eq!(limits.max_body_bytes, 16 * MIB);
assert_eq!(limits.max_result_bytes, 4 * MIB);
assert_eq!(limits.max_export_bytes, 48 * MIB);
assert_eq!(limits.max_download_bytes, 2048 * MIB);
assert_eq!(limits.max_queue_text, 8);
assert_eq!(limits.max_queue_formula, 3);
assert_eq!(limits.max_consecutive_text, 6);
assert_eq!(limits.max_consecutive_formula, 2);
assert_eq!(limits.max_retained, 64);
assert_eq!(limits.max_retained_bytes, 128 * MIB);
assert_eq!(limits.max_tombstones, 512);
assert_eq!(limits.job_ttl_ms, 1_200_000);
assert_eq!(limits.max_eval_cases, 8);
assert_eq!(
args.formula_detector.as_deref(),
Some(std::path::Path::new("D:\\models\\pix2text-mfd-1.5.onnx"))
);
assert_eq!(
args.table_model.as_deref(),
Some(std::path::Path::new("D:\\models\\slanet-plus.onnx"))
);
assert_eq!(
args.eval_root.as_deref(),
Some(std::path::Path::new("D:\\eval-root"))
);
assert!(args.reverify_models);
assert!(!args.uses_documented_defaults());
}
#[test]
fn the_startup_reverification_flag_defaults_to_off_and_takes_no_value() {
assert!(!parse(&[]).reverify_models);
assert!(parse(&["--reverify-models"]).reverify_models);
assert!(
try_parse(&["--reverify-models=true"]).is_err(),
"a boolean flag must not accept a value"
);
assert!(
try_parse(&["--reverify-models", "1"]).is_err(),
"the value after the flag would be an unexpected positional argument"
);
}
#[test]
fn the_three_provider_names_are_the_documented_ones() {
assert_eq!(
parse(&["--provider", "cpu"]).provider,
Some(ProviderChoice::Cpu)
);
assert_eq!(
parse(&["--provider", "directml"]).provider,
Some(ProviderChoice::Directml)
);
assert_eq!(
parse(&["--provider", "cuda"]).provider,
Some(ProviderChoice::Cuda)
);
assert_eq!(
parse(&["--provider", "cpu"]).provider_preference(),
Some(ProviderPreference::Cpu)
);
assert_eq!(
parse(&["--provider", "cuda"]).provider_preference(),
Some(ProviderPreference::Cuda { device_id: 0 })
);
assert!(try_parse(&["--provider", "cann"]).is_err());
assert!(try_parse(&["--provider", "auto"]).is_err());
}
#[test]
fn zero_mib_limits_parse_but_fail_validation_with_the_flag_name() {
let args = parse(&["--max-body-mb", "0"]);
let error = args
.raw_limits()
.validate()
.expect_err("--max-body-mb=0 must not reach the runtime");
assert_eq!(error.field(), "--max-body-mb");
assert!(error.to_string().contains("--max-body-mb=0"), "{error}");
let args = parse(&["--job-ttl-secs", "0"]);
assert_eq!(
args.raw_limits().validate().expect_err("zero TTL").field(),
"--job-ttl-secs"
);
let args = parse(&["--max-eval-cases", "0"]);
assert_eq!(
args.raw_limits()
.validate()
.expect_err("zero eval cases")
.field(),
"--max-eval-cases"
);
}
#[test]
fn absurd_mib_values_are_rejected_and_never_wrap() {
let error = try_parse(&["--max-body-mb", "999999999999999999999999999999"])
.expect_err("an out-of-range integer must be rejected");
let text = error.to_string();
assert!(text.contains("--max-body-mb"), "{text}");
let args = parse(&["--max-body-mb", "17592186044416"]);
let error = args
.raw_limits()
.validate()
.expect_err("2^44 MiB overflows u64 bytes");
assert_eq!(error.field(), "--max-body-mb");
assert!(error.reason().contains("overflows"), "{error}");
assert!(try_parse(&["--max-body-mb", "-1"]).is_err());
assert!(try_parse(&["--port", "abc"]).is_err());
assert!(try_parse(&["--port", "70000"]).is_err());
assert!(try_parse(&["--max-side", "0x10"]).is_err());
}
#[test]
fn the_model_dir_default_comes_from_the_library() {
let args = parse(&[]);
assert_eq!(
args.model_dir(),
rapid_structure_rs::default_model_store_dir()
);
let explicit = parse(&["--model-dir", "D:\\m"]);
assert_eq!(explicit.model_dir(), std::path::PathBuf::from("D:\\m"));
}
#[test]
fn a_missing_config_file_is_a_locatable_startup_error() {
let args = parse(&["--config", "definitely-not-here.yaml"]);
let error = args
.engine_config()
.expect_err("a missing config must fail");
assert!(
error.to_string().contains("definitely-not-here.yaml"),
"{error}"
);
}
}