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>,
}
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,
}
}
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,
}
}
pub fn metered(spend: Spend) -> Self {
Self {
running: true,
pid: None,
listen: None,
models: None,
providers: Vec::new(),
socket: None,
spend: Some(spend),
}
}
}
#[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,
listen,
models,
providers,
}) => Ok(StatusReport::running(
pid,
listen,
models,
providers,
socket.display().to_string(),
spend,
)),
Ok(DaemonResponse::Error { message }) => Err(anyhow::anyhow!(message)),
Ok(other) => Err(anyhow::anyhow!("unexpected response: {other:?}")),
Err(e) if daemon::is_not_reachable(&e) => {
Ok(StatusReport::stopped(socket.display().to_string(), spend))
}
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::*;
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());
}
}