use super::ToolError;
#[derive(
Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct Spend {
pub currency: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spent: Option<Spent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<SpendLimit>,
}
#[derive(
Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct Spent {
pub window: String,
pub estimated_micro_usd: u64,
pub requests: u64,
pub unpriced: u64,
}
#[derive(
Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct SpendLimit {
pub balance_micro_usd: i64,
pub pending_micro_usd: i64,
pub remaining_micro_usd: i64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct StatusReport {
pub running: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listen: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub models: Option<usize>,
#[serde(default)]
pub providers: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub socket: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spend: Option<Spend>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub saved_routers: Option<Vec<crate::actions::models::RouterStatus>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub running_routers: Option<Vec<crate::actions::models::RouterStatus>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub router_restart_required: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_state: Option<crate::reload::ConfigurationState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installed_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daemon_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handoff_protocol: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handoff_build_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handoff_activity: Option<crate::daemon::HandoffActivity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daemon_cli_owned: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compatibility: Option<String>,
}
impl StatusReport {
pub fn stopped(socket: String, spend: Option<Spend>) -> Self {
Self {
running: false,
pid: None,
listen: None,
models: None,
providers: Vec::new(),
socket: Some(socket),
spend,
saved_routers: None,
running_routers: None,
router_restart_required: None,
config_state: None,
installed_version: None,
daemon_version: None,
handoff_protocol: None,
handoff_build_id: None,
handoff_activity: None,
daemon_cli_owned: None,
compatibility: None,
}
}
pub fn running(
pid: u32,
listen: String,
models: usize,
providers: Vec<String>,
socket: String,
spend: Option<Spend>,
) -> Self {
Self {
running: true,
pid: Some(pid),
listen: Some(listen),
models: Some(models),
providers,
socket: Some(socket),
spend,
saved_routers: None,
running_routers: None,
router_restart_required: None,
config_state: None,
installed_version: None,
daemon_version: None,
handoff_protocol: None,
handoff_build_id: None,
handoff_activity: None,
daemon_cli_owned: None,
compatibility: None,
}
}
pub fn metered(spend: Spend) -> Self {
Self {
running: true,
pid: None,
listen: None,
models: None,
providers: Vec::new(),
socket: None,
spend: Some(spend),
saved_routers: None,
running_routers: None,
router_restart_required: None,
config_state: None,
installed_version: None,
daemon_version: None,
handoff_protocol: None,
handoff_build_id: None,
handoff_activity: None,
daemon_cli_owned: None,
compatibility: None,
}
}
pub fn with_router_views(
mut self,
saved: Option<Vec<crate::actions::models::RouterStatus>>,
running: Option<Vec<crate::actions::models::RouterStatus>>,
restart_required: Option<bool>,
) -> Self {
self.router_restart_required = restart_required;
self.saved_routers = saved;
self.running_routers = running;
self
}
pub fn with_config_state(mut self, state: Option<crate::reload::ConfigurationState>) -> Self {
self.config_state = state;
self
}
pub fn with_local_versions(
mut self,
daemon_version: Option<String>,
protocol: Option<u32>,
build_id: Option<String>,
activity: Option<crate::daemon::HandoffActivity>,
cli_owned: Option<bool>,
) -> Self {
self.installed_version = Some(crate::VERSION.to_string());
self.compatibility = if !self.running {
None
} else if protocol != Some(1) || daemon_version.is_none() || build_id.is_none() {
Some("legacy_unknown".to_string())
} else if daemon_version.as_deref() == Some(crate::VERSION)
&& build_id.as_deref() == Some(crate::HANDOFF_BUILD_ID)
{
Some("compatible".to_string())
} else if cli_owned != Some(true) {
Some("externally_managed".to_string())
} else {
let candidate = semver::Version::parse(crate::VERSION);
let resident = daemon_version.as_deref().map(semver::Version::parse);
Some(
match (candidate, resident) {
(Ok(candidate), Some(Ok(resident))) if candidate >= resident => {
"handoff_required"
}
_ => "daemon_newer",
}
.to_string(),
)
};
self.daemon_version = daemon_version;
self.handoff_protocol = protocol;
self.handoff_build_id = build_id;
self.handoff_activity = activity;
self.daemon_cli_owned = cli_owned;
self
}
}
#[async_trait::async_trait]
pub trait StatusQuery: Send + Sync {
async fn status(&self) -> Result<StatusReport, ToolError>;
}
use std::path::{Path, PathBuf};
use crate::daemon::{self, DaemonCommand, DaemonResponse};
use crate::metering::store::TimeWindow;
use crate::paths::ConfigSource;
const SPEND_WINDOW: TimeWindow = TimeWindow::Today;
const SPEND_WINDOW_LABEL: &str = "today";
pub struct DaemonStatus {
socket: PathBuf,
source: Option<ConfigSource>,
}
impl DaemonStatus {
pub fn new(socket: impl Into<PathBuf>, source: Option<ConfigSource>) -> Self {
Self {
socket: socket.into(),
source,
}
}
pub async fn report(&self) -> anyhow::Result<StatusReport> {
report_over(&self.socket, self.source.as_ref()).await
}
}
async fn report_over(socket: &Path, source: Option<&ConfigSource>) -> anyhow::Result<StatusReport> {
let spend = local_spend(source).await;
match daemon::send_command(socket, &DaemonCommand::Status).await {
Ok(DaemonResponse::Status {
pid,
daemon_version,
handoff_protocol,
handoff_build_id,
handoff_activity,
cli_owned,
listen,
models,
providers,
saved_routers,
running_routers,
router_restart_required,
config_state,
}) => Ok(StatusReport::running(
pid,
listen,
models,
providers,
socket.display().to_string(),
spend,
)
.with_router_views(saved_routers, running_routers, router_restart_required)
.with_config_state(config_state.map(|state| *state))
.with_local_versions(
daemon_version,
handoff_protocol,
handoff_build_id,
handoff_activity,
cli_owned,
)),
Ok(DaemonResponse::Error { message }) => Err(anyhow::anyhow!(message)),
Ok(other) => Err(anyhow::anyhow!("unexpected response: {other:?}")),
Err(e) if daemon::is_not_reachable(&e) => {
let saved_routers = match source {
Some(source) => crate::actions::models::disk_router_statuses(source).await,
None => None,
};
let config_state = match source {
Some(source) => {
Some(crate::reload::configuration_state_without_runtime(source).await)
}
None => None,
};
Ok(StatusReport::stopped(socket.display().to_string(), spend)
.with_router_views(saved_routers, None, None)
.with_config_state(config_state)
.with_local_versions(None, None, None, None, None))
}
Err(e) => Err(e),
}
}
async fn local_spend(source: Option<&ConfigSource>) -> Option<Spend> {
let store = crate::metering::reader::open_readonly(source?).await?;
let summary = store.spend_summary(SPEND_WINDOW).await.ok()?;
Some(Spend {
currency: "USD".to_string(),
spent: Some(Spent {
window: SPEND_WINDOW_LABEL.to_string(),
estimated_micro_usd: summary.spend_micro_usd,
requests: summary.requests,
unpriced: summary.unpriced,
}),
limit: None,
})
}
#[async_trait::async_trait]
impl StatusQuery for DaemonStatus {
async fn status(&self) -> Result<StatusReport, ToolError> {
self.report()
.await
.map_err(|e| ToolError::new(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_status_keeps_legacy_and_external_ownership_unknown_or_explicit() {
let base = || {
StatusReport::running(
7,
"127.0.0.1:4356".into(),
0,
Vec::new(),
"/tmp/bitrouter.sock".into(),
None,
)
};
let legacy = base().with_local_versions(None, None, None, None, None);
assert_eq!(legacy.compatibility.as_deref(), Some("legacy_unknown"));
let matching = base().with_local_versions(
Some(crate::VERSION.into()),
Some(1),
Some(crate::HANDOFF_BUILD_ID.into()),
None,
Some(true),
);
assert_eq!(matching.compatibility.as_deref(), Some("compatible"));
let older = base().with_local_versions(
Some("1.0.0-alpha.1".into()),
Some(1),
Some("another-build".into()),
None,
Some(true),
);
assert_eq!(older.compatibility.as_deref(), Some("handoff_required"));
let external = base().with_local_versions(
Some("1.0.0-alpha.1".into()),
Some(1),
Some("another-build".into()),
None,
Some(false),
);
assert_eq!(
external.compatibility.as_deref(),
Some("externally_managed")
);
}
async fn metered_home() -> (tempfile::TempDir, ConfigSource) {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("meter.db");
let db = crate::db::connect(&format!("sqlite://{}", db_path.display()))
.await
.expect("create metering db");
crate::db::run_migrations(&db).await.expect("migrate");
let config = dir.path().join("bitrouter.yaml");
std::fs::write(
&config,
r#"
server:
listen: "127.0.0.1:0"
skip_auth: true
database:
url: "sqlite://meter.db"
providers:
openai:
api_base: https://api.openai.com/v1
api_key: k1
models: [{ id: gpt-5 }]
"#,
)
.expect("write config");
let source = ConfigSource::File(config);
(dir, source)
}
#[tokio::test]
async fn both_surfaces_report_spend_on_a_local_deployment() {
let (dir, source) = metered_home().await;
let socket = dir.path().join("nothing-listening.sock");
let probe = DaemonStatus::new(&socket, Some(source));
let cli = probe.report().await.expect("cli surface");
let port = StatusQuery::status(&probe).await.expect("port surface");
for (surface, report) in [("cli", &cli), ("port", &port)] {
let spend = report
.spend
.as_ref()
.unwrap_or_else(|| panic!("{surface} surface reported no spend"));
let spent = spend
.spent
.as_ref()
.unwrap_or_else(|| panic!("{surface} surface reported no spent half"));
assert_eq!(spent.window, "today");
assert_eq!(spent.estimated_micro_usd, 0);
assert_eq!(spent.requests, 0);
assert_eq!(spent.unpriced, 0);
assert!(spend.limit.is_none(), "{surface} invented a spend cap");
}
assert_eq!(
serde_json::to_value(&cli).expect("cli json"),
serde_json::to_value(&port).expect("port json"),
"the two surfaces of one action must be the same bytes"
);
assert!(!cli.running);
}
#[tokio::test]
async fn an_unreadable_metering_database_costs_spend_not_the_report() {
let dir = tempfile::tempdir().expect("tempdir");
let socket = dir.path().join("nothing-listening.sock");
let report = DaemonStatus::new(&socket, None)
.report()
.await
.expect("a missing metering database must not fail status");
assert!(!report.running);
assert!(report.spend.is_none());
}
}