use std::sync::Arc;
use anyhow::{Context, Result};
use crate::cli;
use crate::main_modules::AppState;
pub(crate) struct Prepared {
pub state: Arc<AppState>,
pub bind: String,
pub port: u16,
pub scheduler: std::thread::JoinHandle<()>,
}
enum Startup {
Serve(Prepared),
Worker,
AwaitingModel,
}
pub(crate) async fn serve(
args: cli::ServeArgs,
tui_progress: Option<std::sync::mpsc::Receiver<crate::tui::capture_layer::ProgressEvent>>,
) -> Result<()> {
let host = Arc::new(crate::main_modules::model_host::ModelHost::empty());
host.set_args(args.clone());
metrale_bench::benchmarks::scheduler_equivalence::host::install(Arc::new(
crate::tui::bench_host::TuiServeHost::new(host.clone(), args.cache_dir.clone()),
));
let (bind_addr, bind_port) = (args.bind.clone(), args.port);
host.set_auth(build_auth_config(&args)?);
host.set_process(super::serve_load::Carried::from_env().map_err(|e| anyhow::anyhow!("{e}"))?);
let startup_host = host.clone();
match tokio::task::spawn_blocking(move || startup(args, tui_progress, startup_host)).await?? {
Startup::Serve(prepared) => {
host.publish(prepared.state);
host.set_scheduler(prepared.scheduler);
crate::main_modules::serve_router::build_and_serve(host, &prepared.bind, prepared.port)
.await
}
Startup::Worker => Ok(()),
Startup::AwaitingModel => {
crate::main_modules::serve_router::build_and_serve(host, &bind_addr, bind_port).await
}
}
}
fn startup(
args: cli::ServeArgs,
tui_progress: Option<std::sync::mpsc::Receiver<crate::tui::capture_layer::ProgressEvent>>,
host: Arc<crate::main_modules::model_host::ModelHost>,
) -> Result<Startup> {
tracing::info!("Metrale Engine starting...");
tracing::info!("Licensed under MIT OR Apache-2.0 — see /LICENSE-MIT and /LICENSE-APACHE");
crate::disk_guard::warn_if_nearly_full(args.cache_dir.as_deref());
metrale_telemetry::progress::phase(0, "banner");
crate::tui::shutdown::install_signal_listeners();
let mut tui_handles_tx: Option<std::sync::mpsc::Sender<crate::tui::RunHandles>> = None;
if let Some(progress_rx) = tui_progress
&& args.rank == 0
{
let tx = crate::tui::start(args.clone(), progress_rx, host.clone());
host.set_tui_handles(tx.clone());
tui_handles_tx = Some(tx);
}
if let Err(msg) = cli::validate_serve_args(&args) {
anyhow::bail!("{msg}");
}
super::serve_flags::publish_kernel_flags(&args);
super::telemetry_boot::start(&args)?;
if args.model.is_none() && args.model_from_path.is_none() {
if tui_handles_tx.is_none() {
anyhow::bail!(
"no model given, and no dashboard to choose one from.\n\
Pass a MODEL (or --model-from-path), or run on a TTY without \
--no-tui to browse the Library."
);
}
tracing::info!("No model specified — open the Library to choose one");
return Ok(Startup::AwaitingModel);
}
let carried = host
.process()
.expect("process-scoped state is installed before startup");
match super::serve_load::load_model(args, tui_handles_tx, carried)? {
Some(prepared) => Ok(Startup::Serve(prepared)),
None => Ok(Startup::Worker),
}
}
#[derive(Debug, Default, PartialEq)]
pub(super) struct DefaultChatTemplateKwargs {
pub thinking: crate::ir::ThinkingDirective,
pub reasoning_effort: Option<crate::ir::ReasoningEffort>,
pub preserve_thinking: Option<bool>,
}
pub(super) fn parse_default_chat_template_kwargs(
s: &str,
) -> anyhow::Result<DefaultChatTemplateKwargs> {
use crate::ir::ThinkingDirective;
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Kwargs {
enable_thinking: Option<bool>,
thinking_budget: Option<u32>,
reasoning_effort: Option<String>,
preserve_thinking: Option<bool>,
}
if s.trim().is_empty() {
return Ok(DefaultChatTemplateKwargs::default());
}
let kw: Kwargs = serde_json::from_str(s)
.map_err(|e| anyhow::anyhow!("--default-chat-template-kwargs is not valid JSON: {e}"))?;
let (reasoning_effort, effort_directive) = match kw.reasoning_effort.as_deref() {
None => (None, None),
Some(v) => match crate::ir::parse_wire_effort(v) {
Some((template_effort, directive)) => (template_effort, Some(directive)),
None => anyhow::bail!(
"--default-chat-template-kwargs reasoning_effort {v:?}: expected one of \
none, minimal, low, medium, high, xhigh, max"
),
},
};
let thinking = match (kw.thinking_budget, kw.enable_thinking) {
(Some(b), _) if b > 0 => ThinkingDirective::On { budget: Some(b) },
(Some(_), _) => ThinkingDirective::Off,
(None, Some(true)) => ThinkingDirective::On { budget: None },
(None, Some(false)) => ThinkingDirective::Off,
(None, None) => effort_directive.unwrap_or(ThinkingDirective::Unspecified),
};
Ok(DefaultChatTemplateKwargs {
thinking,
reasoning_effort,
preserve_thinking: kw.preserve_thinking,
})
}
pub(super) fn build_auth_config(
args: &cli::ServeArgs,
) -> Result<Option<Arc<crate::auth::AuthConfig>>> {
if !args.require_auth {
if args.auth_tokens_file.is_some() || args.auth_token.is_some() {
tracing::warn!(
"--auth-tokens-file / --auth-token supplied without --require-auth; \
tokens are loaded but the auth gate is OFF. Pass --require-auth to enforce."
);
}
return Ok(None);
}
let cfg = match (&args.auth_tokens_file, &args.auth_token) {
(Some(path), None) => crate::auth::AuthConfig::from_file(path)?,
(None, Some(tok)) => {
tracing::warn!(
"--auth-token sets the bearer token via the command line; the value \
is visible to other local users via `ps`/`/proc/<pid>/cmdline`. \
Use --auth-tokens-file with permissions 0600 in production."
);
crate::auth::AuthConfig::from_inline(tok)?
}
(None, None) => {
return Err(anyhow::anyhow!(
"--require-auth was set but neither --auth-tokens-file nor \
--auth-token was supplied. Pick one (a tokens file is preferred)."
));
}
(Some(_), Some(_)) => unreachable!("clap conflicts_with should have rejected this"),
};
tracing::info!(
"auth: require_auth=ON ({} bearer token{} loaded)",
cfg.token_count(),
if cfg.token_count() == 1 { "" } else { "s" },
);
Ok(Some(Arc::new(cfg)))
}
pub(super) fn resolve_vision_max_pixels(
args: &cli::ServeArgs,
model_dir: &std::path::Path,
) -> Result<Option<usize>> {
if args.vision_max_pixels > 0 {
return Ok(Some(args.vision_max_pixels));
}
if let Ok(raw) = std::env::var("METRALE_VISION_MAX_PIXELS") {
let trimmed = raw.trim();
if !trimmed.is_empty() && trimmed != "0" {
let parsed = trimmed.parse::<usize>().with_context(|| {
format!("METRALE_VISION_MAX_PIXELS must be a positive integer, got {raw:?}")
})?;
if parsed > 0 {
return Ok(Some(parsed));
}
}
}
Ok(read_preprocessor_max_pixels(model_dir))
}
pub(super) fn read_preprocessor_max_pixels(model_dir: &std::path::Path) -> Option<usize> {
const SOURCES: [(&str, Option<&str>); 3] = [
("preprocessor_config.json", None),
("preprocessor_config.json", Some("image_processor")),
("processor_config.json", Some("image_processor")),
];
for (file, nest) in SOURCES {
let path = model_dir.join(file);
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
continue;
};
let scope = match nest {
Some(key) => match json.get(key) {
Some(v) => v,
None => continue,
},
None => &json,
};
let from_size = scope
.get("size")
.and_then(|s| s.get("longest_edge"))
.and_then(serde_json::Value::as_u64);
let direct = scope.get("max_pixels").and_then(serde_json::Value::as_u64);
let Some(px) = from_size.or(direct).filter(|&p| p > 0) else {
continue;
};
tracing::info!(
"Vision area bound {} px from {}{} (was: hard-coded 1280px long side)",
px,
path.display(),
nest.map(|k| format!(" [{k}]")).unwrap_or_default(),
);
return Some(px as usize);
}
None
}
#[path = "serve_quant.rs"]
mod quant;
pub(super) use quant::{canonicalize_model_quant, describe_quant_source, quant_pair_compatible};
#[cfg(test)]
#[path = "serve_tests.rs"]
mod tests;