use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::bail;
use leviath_core::blueprint::ModelConfig;
use leviath_providers::{InferenceRequest, Message, Provider};
use leviath_runtime::ProviderRegistry;
use leviath_runtime::control_socket::{ControlClient, ControlResponse};
use leviath_runtime::pipeline::{providers_tried, resolve_stage_model};
use crate::commands::run::session::build_provider_registry_from_config;
use crate::config::Config;
use crate::daemon::spawn::model_defaults;
pub const DOCTOR_LONG_ABOUT: &str = "\
Check that provider wiring works, end to end.
Four checks run in order, and the first failure stops the rest. The check that
fails is the diagnosis:
config the config file parses and a provider registry can be built.
Fails on a malformed config.toml.
resolve your default provider/model picks a provider that is actually
registered. Fails when a key is missing or misspelled - and
catches the case where a blueprint with no model falls back to
anthropic on a machine that has no Anthropic key.
inference one real call to that provider. Fails on a bad key, an unknown
model id, or a billing problem; the provider's own error is
printed verbatim, status line and response body included.
daemon a one-stage agent spawned over the control socket, waited on,
then deleted. Fails when the handoff is broken even though the
credentials are fine.
So config/resolve/inference OK with daemon FAIL means the daemon is the
problem, not your keys - the distinction this command exists to make.
`--model` takes the same forms `lev run --model` does: `provider/model` picks
both (the way to reach a Rhai script provider, which cannot be listed), and a
bare model id pairs with your default_provider. Use it to try a model string
before wiring it into a blueprint.
Two inferences are billed per run, capped at 64 output tokens each.
`--no-daemon` stops after the third check and bills one.
Exits non-zero on failure, so it works as a CI gate. --json prints the same
checks as {\"checks\": [...], \"passed\": bool}.";
#[derive(clap::Args, Debug, Clone, Default)]
pub struct DoctorArgs {
#[arg(short, long)]
pub model: Option<String>,
#[arg(long)]
pub no_daemon: bool,
#[arg(long)]
pub json: bool,
}
const DAEMON_TIMEOUT: Duration = Duration::from_secs(90);
const DAEMON_POLL: Duration = Duration::from_millis(250);
const PROBE_MAX_TOKENS: usize = 64;
const PROBE_PROMPT: &str = "Reply with exactly: PONG";
const PROBE_EXPECTED: &str = "PONG";
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckStatus {
Ok,
Fail,
}
impl CheckStatus {
fn label(&self) -> &'static str {
match self {
Self::Ok => "OK",
Self::Fail => "FAIL",
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Check {
pub name: &'static str,
pub status: CheckStatus,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub elapsed_ms: Option<u64>,
}
impl Check {
fn ok(name: &'static str, detail: impl Into<String>) -> Self {
Self {
name,
status: CheckStatus::Ok,
detail: detail.into(),
elapsed_ms: None,
}
}
fn fail(name: &'static str, detail: impl Into<String>) -> Self {
Self {
name,
status: CheckStatus::Fail,
detail: detail.into(),
elapsed_ms: None,
}
}
fn timed(mut self, elapsed: Duration) -> Self {
self.elapsed_ms = Some(elapsed.as_millis() as u64);
self
}
}
pub fn format_report(checks: &[Check]) -> String {
let name_width = checks.iter().map(|c| c.name.len()).max().unwrap_or(0);
let status_width = checks
.iter()
.map(|c| c.status.label().len())
.max()
.unwrap_or(0);
let mut out = String::from("\n");
for check in checks {
out.push_str(&format!(
" {:<name_width$} {:<status_width$} {}",
check.name,
check.status.label(),
check.detail,
));
if let Some(ms) = check.elapsed_ms {
out.push_str(&format!(" ({:.1}s)", ms as f64 / 1000.0));
}
out.push('\n');
}
if checks.iter().all(|c| c.status == CheckStatus::Ok) {
out.push_str("\ndoctor passed\n");
}
out
}
fn misdirected_rate_limits(config: &Config) -> Vec<String> {
let known: Vec<&str> = crate::commands::setup::catalog::providers()
.iter()
.map(|p| p.id)
.collect();
let mut misdirected: Vec<String> = config
.rate_limits
.keys()
.filter(|name| !known.contains(&name.as_str()))
.map(|name| format!("rate_limits.{name}"))
.collect();
misdirected.sort_unstable();
misdirected
}
fn config_check(config: &Config, registry: &ProviderRegistry) -> Check {
let mut names = registry.provider_names();
names.sort_unstable();
let registered = match names.is_empty() {
true => "none".to_string(),
false => names.join(", "),
};
let detail = format!(
"default_provider={}; registered: {} (script providers resolve by name)",
config.default_provider, registered
);
let mut unread = Config::unread_keys_at(&Config::config_path());
unread.extend(misdirected_rate_limits(config));
if unread.is_empty() {
return Check::ok("config", detail);
}
let subject = match unread.len() {
1 => "1 key in config.toml is",
n => &format!("{n} keys in config.toml are"),
};
Check::ok(
"config",
format!(
"{detail} (note: {subject} read by nothing - check the spelling: {})",
unread.join(", ")
),
)
}
struct Resolved {
provider_name: String,
model: String,
provider: Arc<dyn Provider>,
}
fn resolve_check(
config: &Config,
model_override: Option<&str>,
registry: &ProviderRegistry,
) -> (Check, Option<Resolved>) {
let empty = ModelConfig {
models: Vec::new(),
allow_user_default: true,
parameters: std::collections::HashMap::new(),
request_timeout_secs: None,
};
let defaults = model_defaults(config);
let (provider_name, model) = resolve_stage_model(&empty, model_override, &defaults, registry);
match registry.get(&provider_name) {
Some(provider) => (
Check::ok(
"resolve",
format!(
"{provider_name} / {model}{}",
default_provider_note(config, &provider_name, model_override, registry)
),
),
Some(Resolved {
provider_name,
model,
provider,
}),
),
None => (
Check::fail(
"resolve",
format!(
"resolved to '{provider_name}', which is not configured (tried: {}). \
Configure it with `lev setup`, or add it to config.toml.",
providers_tried(&empty, model_override, &defaults)
),
),
None,
),
}
}
fn default_provider_note(
config: &Config,
resolved: &str,
model_override: Option<&str>,
registry: &ProviderRegistry,
) -> String {
if model_override.is_some() || resolved == config.default_provider {
return String::new();
}
if config.default_model.is_some() || !registry.has(&config.default_provider) {
return String::new();
}
let named = &config.default_provider;
format!(
" (note: default_provider is '{named}' but no default_model is set, \
so it is never chosen - add `default_model` to config.toml)"
)
}
async fn inference_check(provider: &dyn Provider, model: &str) -> Check {
let caps = provider.capabilities(model);
let request = InferenceRequest {
system: Vec::new(),
messages: vec![Message {
role: "user".to_string(),
content: PROBE_PROMPT.into(),
cache_breakpoint: false,
}],
model: model.to_string(),
max_tokens: PROBE_MAX_TOKENS.min(caps.max_output_tokens),
temperature: 0.0,
tools: Vec::new(),
extra: serde_json::Value::Null,
request_timeout_secs: Some(60),
};
let started = Instant::now();
match provider.infer(&request).await {
Ok(response) => {
let usage = response.tokens_used;
let echo = match response.content.contains(PROBE_EXPECTED) {
true => format!("replied {PROBE_EXPECTED}"),
false => format!("no {PROBE_EXPECTED} in the reply"),
};
Check::ok(
"inference",
format!(
"{} in / {} out / {} total, {echo}",
usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
),
)
.timed(started.elapsed())
}
Err(e) => Check::fail("inference", e.to_string()).timed(started.elapsed()),
}
}
fn canary_manifest(provider: &str, model: &str) -> String {
let provider = serde_json::to_string(provider).expect("a str always serializes to JSON");
let model = serde_json::to_string(model).expect("a str always serializes to JSON");
format!(
r#"[agent]
name = "doctor"
version = "0.0.1"
description = "One-turn provider probe spawned by `lev doctor`, deleted when it finishes."
entry_stage = "ping"
[stages.ping]
mode = "autonomous"
model = {{ models = [{{ provider = {provider}, model = {model} }}] }}
description = "Answer once, in text."
available_tools = []
max_iterations = 1
system_prompt = "Reply with exactly: {PROBE_EXPECTED}. Call no tools."
[context.regions]
task = {{ kind = "pinned", max_tokens = 1000, seed = "task" }}
conversation = {{ kind = "sliding_window", max_items = 4, max_tokens = 2000 }}
"#
)
}
fn cleanup_run(run_id: &str) {
let _ = crate::runstate::force_cancel(run_id);
let _ = std::fs::remove_dir_all(crate::runstate::run_dir(run_id));
let _ = leviath_core::paths::data_dir().map(|d| {
let _ = std::fs::remove_dir_all(d.join("state").join(run_id));
});
}
enum DaemonOutcome {
Complete(String),
Failed(String),
}
fn stage_canary(
root: &std::path::Path,
provider: &str,
model: &str,
) -> std::io::Result<std::path::PathBuf> {
let agent_dir = root.join("doctor");
std::fs::create_dir_all(&agent_dir)?;
let manifest = agent_dir.join("agent.leviath");
std::fs::write(&manifest, canary_manifest(provider, model))?;
Ok(manifest)
}
async fn daemon_check(
client: &ControlClient,
provider_name: &str,
model: &str,
timeout: Duration,
poll: Duration,
root: &std::path::Path,
) -> Check {
let started = Instant::now();
let manifest = match stage_canary(root, provider_name, model) {
Ok(manifest) => manifest,
Err(e) => return Check::fail("daemon", format!("could not stage a probe agent: {e}")),
};
match spawn_and_wait(client, &manifest, root, timeout, poll).await {
DaemonOutcome::Complete(detail) => Check::ok("daemon", detail).timed(started.elapsed()),
DaemonOutcome::Failed(detail) => Check::fail("daemon", detail).timed(started.elapsed()),
}
}
async fn spawn_and_wait(
client: &ControlClient,
manifest: &std::path::Path,
workdir: &std::path::Path,
timeout: Duration,
poll: Duration,
) -> DaemonOutcome {
let args = crate::daemon::client::resolve_spawn_args(crate::daemon::client::LaunchRequest {
path: &manifest.to_string_lossy(),
task: Some(PROBE_PROMPT),
stdin_is_terminal: &|| false,
model: None,
workdir: &workdir.to_string_lossy(),
yolo: true,
allow: Vec::new(),
max_depth: None,
regions: std::collections::HashMap::new(),
no_seed_commands: false,
output_request: None,
});
let args = match args {
Ok(args) => args,
Err(e) => return DaemonOutcome::Failed(format!("could not build the spawn request: {e}")),
};
let run_id = args.run_id.clone();
let spawned = match client.spawn(args).await {
Ok(ControlResponse::Spawned { run_id }) => Ok(run_id),
Ok(ControlResponse::Error { message }) => {
Err(format!("the daemon refused the spawn: {message}"))
}
Ok(other) => Err(format!("unexpected daemon response to spawn: {other:?}")),
Err(e) => Err(format!(
"the daemon is not reachable ({e}); start it with `lev daemon`"
)),
};
let run_id = match spawned {
Ok(id) => id,
Err(detail) => {
cleanup_run(&run_id);
return DaemonOutcome::Failed(detail);
}
};
let outcome = wait_for_run(client, &run_id, timeout, poll).await;
cleanup_run(&run_id);
outcome
}
async fn wait_for_run(
client: &ControlClient,
run_id: &str,
timeout: Duration,
poll: Duration,
) -> DaemonOutcome {
let started = Instant::now();
loop {
let still = match client.status(run_id).await {
Ok(ControlResponse::Status {
status: Some(status),
}) => {
if leviath_runtime::pipeline::is_terminal_status(&status) {
return finished(run_id, &status);
}
status.label()
}
Ok(ControlResponse::Status { status: None }) => return reaped(run_id),
Ok(other) => {
return DaemonOutcome::Failed(format!("unexpected daemon response: {other:?}"));
}
Err(e) => {
return DaemonOutcome::Failed(format!("lost contact with the daemon: {e}"));
}
};
if started.elapsed() >= timeout {
return DaemonOutcome::Failed(format!(
"the run was still '{still}' after {}s - the daemon took the spawn but is not \
getting anywhere. Check `lev ps` for the lane footer.",
timeout.as_secs()
));
}
tokio::time::sleep(poll).await;
}
}
fn finished(run_id: &str, status: &leviath_runtime::components::AgentStatus) -> DaemonOutcome {
use leviath_runtime::components::AgentStatus;
let iterations = crate::runstate::read_meta(run_id)
.map(|m| m.iteration)
.unwrap_or(0);
match status {
AgentStatus::Complete => DaemonOutcome::Complete(format!(
"run {run_id} complete after {iterations} iteration(s)"
)),
AgentStatus::Error { message } => {
DaemonOutcome::Failed(format!("run {run_id} ended in error: {message}"))
}
other => DaemonOutcome::Failed(format!("run {run_id} ended {}", other.label())),
}
}
fn reaped(run_id: &str) -> DaemonOutcome {
match crate::runstate::read_meta(run_id) {
Ok(meta) if crate::runstate::is_terminal_status(&meta.status) => match meta.error {
Some(err) => DaemonOutcome::Failed(format!("run {run_id} ended in error: {err}")),
None => DaemonOutcome::Complete(format!(
"run {run_id} {} after {} iteration(s)",
meta.status, meta.iteration
)),
},
_ => DaemonOutcome::Failed(format!(
"run {run_id} vanished before it finished; the daemon accepted the spawn but \
never completed it"
)),
}
}
pub enum DaemonTarget<'a> {
Skip,
Client(&'a ControlClient),
Unavailable(String),
}
pub async fn run_checks(
args: &DoctorArgs,
build_registry: &(
dyn Fn(&Config) -> Result<ProviderRegistry, leviath_providers::ProviderError> + Sync
),
daemon: DaemonTarget<'_>,
) -> Vec<Check> {
let mut checks = Vec::new();
let config = match Config::load() {
Ok(config) => config,
Err(e) => {
checks.push(Check::fail("config", e.to_string()));
return checks;
}
};
for warning in config.validate_keys() {
eprintln!("Warning: {warning}");
}
let registry = match build_registry(&config) {
Ok(registry) => registry,
Err(e) => {
checks.push(Check::fail(
"providers",
format!("could not build any provider client: {e}"),
));
return checks;
}
};
checks.push(config_check(&config, ®istry));
let (check, resolved) = resolve_check(&config, args.model.as_deref(), ®istry);
checks.push(check);
let Some(resolved) = resolved else {
return checks;
};
let check = inference_check(resolved.provider.as_ref(), &resolved.model).await;
let inference_failed = check.status == CheckStatus::Fail;
checks.push(check);
if inference_failed {
return checks;
}
match daemon {
DaemonTarget::Skip => {}
DaemonTarget::Unavailable(reason) => checks.push(Check::fail("daemon", reason)),
DaemonTarget::Client(client) => {
let stage = tempfile::tempdir().expect("the system temp directory is writable");
checks.push(
daemon_check(
client,
&resolved.provider_name,
&resolved.model,
DAEMON_TIMEOUT,
DAEMON_POLL,
stage.path(),
)
.await,
);
}
}
checks
}
async fn execute_with_registry(
args: DoctorArgs,
build_registry: &(
dyn Fn(&Config) -> Result<ProviderRegistry, leviath_providers::ProviderError> + Sync
),
daemon: DaemonTarget<'_>,
) -> anyhow::Result<()> {
let checks = run_checks(&args, build_registry, daemon).await;
let failed = checks.iter().find(|c| c.status == CheckStatus::Fail);
if args.json {
let report = serde_json::json!({
"checks": checks,
"passed": failed.is_none(),
});
println!(
"{}",
serde_json::to_string_pretty(&report).expect("a Check report always serializes")
);
} else {
print!("{}", format_report(&checks));
}
match failed {
Some(check) => bail!("doctor failed at: {}", check.name),
None => Ok(()),
}
}
pub async fn execute(args: DoctorArgs, daemon: DaemonTarget<'_>) -> anyhow::Result<()> {
execute_with_registry(args, &build_provider_registry_from_config, daemon).await
}
#[cfg(test)]
mod tests;