From 3f90f6a1c47b8cb2a392e7895a5ca1e7172df1d8 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Wed, 21 Oct 2026 10:28:48 -0700 Subject: [PATCH] feat(desktop): show recorded costs and savings Signed-off-by: Elyas Mehtabuddin --- Cargo.lock | 3 + crates/switchyard-costs/src/lib.rs | 5 + crates/switchyard-desktop/Cargo.toml | 8 +- crates/switchyard-desktop/src/config.rs | 165 +++++++ crates/switchyard-desktop/src/health.rs | 149 ++++++ crates/switchyard-desktop/src/history.rs | 33 ++ crates/switchyard-desktop/src/install.rs | 70 +-- .../switchyard-desktop/src/install/macos.rs | 4 +- crates/switchyard-desktop/src/lib.rs | 5 +- crates/switchyard-desktop/src/main.rs | 111 +++++ crates/switchyard-desktop/src/pricing.rs | 190 ++++++++ crates/switchyard-desktop/src/rollup.rs | 456 ++++++++++++++++++ crates/switchyard-desktop/src/summary.rs | 155 ++++++ crates/switchyard-desktop/tests/desktop.rs | 6 + crates/switchyard-desktop/tests/print.rs | 270 +++++++++++ 15 files changed, 1581 insertions(+), 49 deletions(-) create mode 100644 crates/switchyard-desktop/src/config.rs create mode 100644 crates/switchyard-desktop/src/health.rs create mode 100644 crates/switchyard-desktop/src/history.rs create mode 100644 crates/switchyard-desktop/src/main.rs create mode 100644 crates/switchyard-desktop/src/pricing.rs create mode 100644 crates/switchyard-desktop/src/rollup.rs create mode 100644 crates/switchyard-desktop/src/summary.rs create mode 100644 crates/switchyard-desktop/tests/print.rs diff --git a/Cargo.lock b/Cargo.lock index 8773c11b35..662c666042 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3655,6 +3655,7 @@ dependencies = [ name = "switchyard-desktop" version = "0.3.0" dependencies = [ + "chrono", "dbus-secret-service", "image", "libc", @@ -3665,10 +3666,12 @@ dependencies = [ "serde", "serde_json", "switchyard-costs", + "switchyard-protocol", "switchyard-runner", "tempfile", "toml 0.9.12+spec-1.1.0", "toml_edit 0.25.13+spec-1.1.0", + "url", "windows-sys 0.61.2", ] diff --git a/crates/switchyard-costs/src/lib.rs b/crates/switchyard-costs/src/lib.rs index 8cb739fa98..6c9de8be5d 100644 --- a/crates/switchyard-costs/src/lib.rs +++ b/crates/switchyard-costs/src/lib.rs @@ -73,6 +73,11 @@ pub struct RecordedCost { } impl Pricing { + /// This method reports whether the file includes rates for a model. + pub fn contains(&self, model: &str) -> bool { + self.0.contains_key(model) + } + /// This method loads the rates shipped with this version of Switchyard. pub fn bundled() -> Result { Self::parse_toml(include_str!("../prices.toml")) diff --git a/crates/switchyard-desktop/Cargo.toml b/crates/switchyard-desktop/Cargo.toml index 53ab0c50e2..757223f196 100644 --- a/crates/switchyard-desktop/Cargo.toml +++ b/crates/switchyard-desktop/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "switchyard-desktop" version.workspace = true -description = "Installer and server key helper for a local Switchyard server" +description = "Installer and usage summary for a local Switchyard server" authors.workspace = true edition.workspace = true homepage = "https://github.com/NVIDIA-NeMo/Switchyard" @@ -12,15 +12,19 @@ license.workspace = true repository.workspace = true rust-version.workspace = true publish = false +default-run = "switchyard-desktop" [dependencies] switchyard-runner.workspace = true switchyard-costs.workspace = true +switchyard-protocol.workspace = true +chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] } serde.workspace = true serde_json.workspace = true tempfile = "3" toml = "0.9" toml_edit = "0.25" +url = "2" [target.'cfg(target_os = "macos")'.dependencies] security-framework = "3" @@ -35,7 +39,7 @@ dbus-secret-service = { version = "4.1", features = ["crypto-rust", "vendored"] [target.'cfg(windows)'.dependencies] mslnk = "0.1.8" self-replace = "1.5" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_Security_Credentials", "Win32_System_Console"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_Security_Credentials", "Win32_System_Console", "Win32_Storage_FileSystem"] } [dev-dependencies] image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/switchyard-desktop/src/config.rs b/crates/switchyard-desktop/src/config.rs new file mode 100644 index 0000000000..0bdddae025 --- /dev/null +++ b/crates/switchyard-desktop/src/config.rs @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module reads desktop settings and supplies installation defaults. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::install::Settings; + +pub(crate) const DEFAULT_PORT: u16 = 4123; +pub(crate) const HOME_DIR: &str = ".switchyard"; +pub(crate) const SERVER_LABEL: &str = "com.nvidia.switchyard.server"; +pub(crate) const ROUTING_LOG: &str = "routing.jsonl"; +pub(crate) const SERVER_CONFIG: &str = "composite.toml"; +pub(crate) const PRICES_FILE: &str = "prices.json"; +pub(crate) const DESKTOP_CONFIG: &str = "desktop.toml"; + +pub(crate) fn server_url(port: u16) -> String { + format!("http://127.0.0.1:{port}") +} + +pub(crate) fn home_dir() -> Result { + std::env::home_dir() + .filter(|home| home.is_absolute()) + .ok_or_else(|| "Could not find your home directory.".into()) +} + +/// Config stores the settings the apps use to find the server and compare costs. +/// Missing keys use the selected installation directory and port. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + /// This field sets the HTTP base URL used for health checks; an omitted port means 80. + pub server_url: String, + /// This field names the JSONL file where the server appends routing records. + pub routing_log: PathBuf, + /// This field names the server TOML file opened by "Open server config". + pub config_file: PathBuf, + /// This field names the LaunchAgent used to restart the server. + pub launchd_label: String, + /// This field sets the usage refresh interval in seconds. + pub refresh_seconds: u64, + /// This field names the model whose rates price the answers as if routing had not run. + pub baseline_model: String, + /// This optional file replaces bundled rates for the baseline estimate. + #[serde(rename = "pricing_file", skip_serializing_if = "Option::is_none")] + pub prices_file: Option, +} + +impl Config { + pub(crate) fn for_directory(directory: &Path, port: u16) -> Self { + Self { + server_url: server_url(port), + routing_log: directory.join(ROUTING_LOG), + config_file: directory.join(SERVER_CONFIG), + launchd_label: SERVER_LABEL.into(), + refresh_seconds: 30, + baseline_model: "gpt-6-sol".into(), + prices_file: None, + } + } + + /// This function parses settings and uses adjacent `install.toml` for directory and port defaults. + /// Without installation metadata, default paths use the settings directory and the port is 4123. + /// An omitted pricing_file selects bundled rates. + pub fn load(path: &Path) -> Result { + let directory = path.parent().unwrap_or(Path::new(".")); + let metadata = directory.join("install.toml"); + let defaults = if metadata.try_exists().map_err(|e| e.to_string())? { + let settings = Settings::load(&metadata)?; + Self::for_directory(&settings.sy_home, settings.port) + } else { + Self::for_directory(directory, DEFAULT_PORT) + }; + let mut values = toml::Table::try_from(defaults).map_err(|e| e.to_string())?; + match std::fs::read_to_string(path) { + Ok(text) => { + let overrides: toml::Table = toml::from_str(&text) + .map_err(|error| format!("parse {}: {error}", path.display()))?; + values.extend(overrides); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("read {}: {error}", path.display())), + } + let moved = if values.contains_key("prices") { + " Remove the prices table to use bundled rates, or convert it to prices.toml and select it with pricing_file." + } else { + "" + }; + let mut config: Self = values + .try_into() + .map_err(|error| format!("parse {}: {error}{moved}", path.display()))?; + crate::health::validate_url(&config.server_url)?; + config.routing_log = expand_home(&config.routing_log)?; + config.config_file = expand_home(&config.config_file)?; + config.prices_file = config.prices_file.as_deref().map(expand_home).transpose()?; + Ok(config) + } + + /// The app reads bundled installation metadata so shortcuts retain a custom `SY_HOME`. + /// Without that metadata, `SY_HOME` selects the directory; if unset, it is `~/.switchyard`. + pub fn default_path() -> Result { + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + let metadata = crate::install::bundle_metadata(&executable); + if metadata.try_exists().map_err(|e| e.to_string())? { + return Settings::load(&metadata).map(|s| s.desktop_settings()); + } + let directory = match std::env::var_os("SY_HOME") { + Some(value) => expand_home(Path::new(&value))?, + None => home_dir()?.join(HOME_DIR), + }; + if !directory.is_absolute() { + return Err("SY_HOME must be an absolute path.".into()); + } + Ok(directory.join(DESKTOP_CONFIG)) + } +} + +pub fn validate_toml_file(path: &Path) -> Result<(), String> { + let text = std::fs::read_to_string(path) + .map_err(|error| format!("read {}: {error}", path.display()))?; + toml::from_str::(&text) + .map_err(|error| format!("parse {}: {error}", path.display()))?; + Ok(()) +} + +/// This function expands a leading `~` in manually written settings and reports a missing home. +pub(crate) fn expand_home(path: &Path) -> Result { + match path.strip_prefix("~") { + Ok(rest) => Ok(home_dir()?.join(rest)), + Err(_) => Ok(path.to_path_buf()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn missing_settings_use_the_selected_directory_without_creating_files() { + let dir = tempfile::tempdir().expect("fixture"); + let config = Config::load(&dir.path().join("desktop.toml")).expect("defaults"); + assert_eq!(config.routing_log, dir.path().join("routing.jsonl")); + assert!(config.prices_file.is_none()); + assert_eq!(config.config_file, dir.path().join("composite.toml")); + assert_eq!(config.server_url, "http://127.0.0.1:4123"); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 0); + } + + #[test] + fn invalid_price_input_forms_reject_settings() { + let dir = tempfile::tempdir().expect("fixture"); + let path = dir.path().join("desktop.toml"); + std::fs::write(&path, "refresh_second=30").expect("settings"); + assert!(Config::load(&path).is_err()); + std::fs::write( + &path, + "[prices.model]\ninput_per_mtok=1.0\noutput_per_mtok=1.0\n", + ) + .expect("settings"); + let error = Config::load(&path).expect_err("prices moved"); + assert!(error.contains("prices.toml"), "{error}"); + } +} diff --git a/crates/switchyard-desktop/src/health.rs b/crates/switchyard-desktop/src/health.rs new file mode 100644 index 0000000000..93f9345dd8 --- /dev/null +++ b/crates/switchyard-desktop/src/health.rs @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module checks the configured server's `/health` endpoint. +//! +//! The health check supports plain HTTP base URLs. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +/// TIMEOUT limits each connection, read, and write operation. +const TIMEOUT: Duration = Duration::from_millis(500); + +/// ServerStatus records whether the server answered its health check. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServerStatus { + Running, + Stopped, +} + +/// This function probes `GET /health`, treating any failure as a stopped server. +pub fn probe(server_url: &str) -> ServerStatus { + match request(server_url) { + Ok(true) => ServerStatus::Running, + _ => ServerStatus::Stopped, + } +} + +fn request(server_url: &str) -> std::io::Result { + let authority = authority(server_url).map_err(std::io::Error::other)?; + // `localhost` can resolve to `::1` before `127.0.0.1`, so try every address. + let mut stream = Err(std::io::Error::other(format!("no address for {authority}"))); + for address in authority.to_socket_addrs()? { + stream = TcpStream::connect_timeout(&address, TIMEOUT); + if stream.is_ok() { + break; + } + } + let mut stream = stream?; + stream.set_read_timeout(Some(TIMEOUT))?; + stream.set_write_timeout(Some(TIMEOUT))?; + stream.write_all(format!("GET /health HTTP/1.0\r\nHost: {authority}\r\n\r\n").as_bytes())?; + let mut status_line = String::new(); + BufReader::new(stream).read_line(&mut status_line)?; + Ok(status_line.split_whitespace().nth(1) == Some("200")) +} + +fn authority(server_url: &str) -> Result { + let url = validate_url(server_url)?; + // Url removes an explicit HTTP port of 80, so a missing parsed port still means 80. + let port = url.port_or_known_default().ok_or("Missing server port")?; + Ok(format!( + "{}:{port}", + &url[url::Position::BeforeHost..url::Position::AfterHost] + )) +} + +pub(crate) fn validate_url(server_url: &str) -> Result { + if server_url.trim() != server_url + || server_url.chars().any(char::is_control) + || !server_url + .split_once("://") + .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http")) + { + return Err( + "Server URL must start with http:// and contain no whitespace or control characters." + .into(), + ); + } + let url = + url::Url::parse(server_url).map_err(|error| format!("Invalid server URL: {error}"))?; + if url.scheme() != "http" + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !matches!(url.path(), "" | "/") + { + return Err( + "Server URL must be an HTTP base URL with a host and optional port, \ + without credentials, a path, query, or fragment." + .into(), + ); + } + Ok(url) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn health_urls_match_the_supported_probe_forms() { + for value in [ + "http://127.0.0.1:4123", + "http://localhost", + "http://[::1]:4123/", + ] { + assert!(validate_url(value).is_ok(), "{value}"); + } + for value in [ + "https://localhost", + "localhost:4123", + "http://user@localhost", + "http://localhost/v1", + "http://localhost?x=1", + "http://localhost#x", + " http://localhost", + "http://local\nhost", + "http:/localhost", + ] { + assert!(validate_url(value).is_err(), "{value}"); + } + } + + #[test] + fn health_checks_use_http_ports_including_explicit_port_80() { + for (url, expected) in [ + ("http://127.0.0.1:80", "127.0.0.1:80"), + ("http://localhost", "localhost:80"), + ("http://localhost:4123", "localhost:4123"), + ("http://[::1]:80/", "[::1]:80"), + ("http://[::1]:4567/", "[::1]:4567"), + ] { + assert_eq!(authority(url).expect("valid URL"), expected); + } + } + + // The server listens on IPv4 only, while `localhost` often resolves to `::1` first. + #[test] + fn probe_reaches_an_ipv4_server_through_localhost() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("address").port(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + let mut request = [0; 256]; + let _ = std::io::Read::read(&mut stream, &mut request); + stream + .write_all(b"HTTP/1.0 200 OK\r\n\r\n") + .expect("respond"); + }); + assert_eq!( + probe(&format!("http://localhost:{port}")), + ServerStatus::Running + ); + server.join().expect("server"); + } +} diff --git a/crates/switchyard-desktop/src/history.rs b/crates/switchyard-desktop/src/history.rs new file mode 100644 index 0000000000..feebe62950 --- /dev/null +++ b/crates/switchyard-desktop/src/history.rs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module describes one completed model call in the routing log. + +use serde::Deserialize; + +#[derive(Debug, Deserialize, serde::Serialize)] +pub struct Entry { + pub ts: String, + pub model: String, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub turn_id: Option, + #[serde(default)] + pub route_id: String, + #[serde(default)] + pub tier: String, + #[serde(default)] + pub prompt_tokens: u64, + #[serde(default)] + pub cached_tokens: u64, + #[serde(default)] + pub cache_creation_tokens: u64, + #[serde(default)] + pub cache_creation_1h_tokens: u64, + #[serde(default)] + pub completion_tokens: u64, + /// The server records the charge or estimate when it has rates; older records have none. + #[serde(default)] + pub cost: Option, +} diff --git a/crates/switchyard-desktop/src/install.rs b/crates/switchyard-desktop/src/install.rs index e173f8d0d6..4e4bd7837d 100644 --- a/crates/switchyard-desktop/src/install.rs +++ b/crates/switchyard-desktop/src/install.rs @@ -16,6 +16,10 @@ use std::{ use switchyard_costs::Pricing; +use crate::config::{ + self, Config, DEFAULT_PORT, DESKTOP_CONFIG, HOME_DIR, PRICES_FILE, ROUTING_LOG, SERVER_CONFIG, +}; + pub mod credentials; // Linux and any other system without launchd or Task Scheduler use systemd; execute rejects // unsupported systems before they change anything. @@ -36,7 +40,6 @@ pub fn executable_name(name: &str) -> String { format!("{name}{}", std::env::consts::EXE_SUFFIX) } -const SERVER: &str = "com.nvidia.switchyard.server"; const BUNDLED_CONFIG: &str = include_str!("../../../scripts/config/composite.toml"); const UPDATE_LOCK: &str = ".switchyard-desktop-update.lock"; const PORT_ERROR: &str = "SY_PORT must be a number from 1 to 65535."; @@ -87,10 +90,7 @@ impl Settings { Ok(selected) } pub fn from_env() -> Result { - let home = std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) - .ok_or("Could not find your home directory.")?; + let home = config::home_dir()?; let path = |name, default| std::env::var_os(name).map(PathBuf::from).unwrap_or(default); let value = |name: &str, default: &str| { std::env::var(name).or_else(|error| match error { @@ -98,12 +98,12 @@ impl Settings { std::env::VarError::NotUnicode(_) => Err(format!("{name} must be UTF-8.")), }) }; - let port = value("SY_PORT", "4123")?; + let port = value("SY_PORT", &DEFAULT_PORT.to_string())?; if !port.bytes().all(|b| b.is_ascii_digit()) { return Err(PORT_ERROR.into()); } let settings = Self { - sy_home: path("SY_HOME", home.join(".switchyard")), + sy_home: path("SY_HOME", home.join(HOME_DIR)), codex_home: path("CODEX_HOME", home.join(".codex")), service_dir: if cfg!(target_os = "macos") { home.join("Library/LaunchAgents") @@ -202,11 +202,11 @@ impl Settings { self.bin_dir().join(executable_name(name)) } pub fn desktop_settings(&self) -> PathBuf { - self.sy_home.join("desktop.toml") + self.sy_home.join(DESKTOP_CONFIG) } /// This optional file overrides bundled rates for the server and app baseline. pub fn prices_file(&self) -> PathBuf { - self.sy_home.join("prices.json") + self.sy_home.join(PRICES_FILE) } /// This method builds the installed server command with any existing price override. /// The caller configures and starts the process. @@ -214,12 +214,12 @@ impl Settings { let mut command = Command::new(self.binary("switchyard-server")); command .arg("--config") - .arg(self.sy_home.join("composite.toml")); + .arg(self.sy_home.join(SERVER_CONFIG)); command.args(["--host", "127.0.0.1"]); command.arg("--port").arg(self.port.to_string()); command .arg("--routing-log-file") - .arg(self.sy_home.join("routing.jsonl")); + .arg(self.sy_home.join(ROUTING_LOG)); let prices = self.prices_file(); if prices.exists() { @@ -491,8 +491,8 @@ fn installation_plan(s: &Settings, action: &str) -> String { ]; for (path, label) in [ (s.desktop_settings(), "app settings"), - (s.sy_home.join("composite.toml"), "server config"), - (s.sy_home.join("routing.jsonl"), "usage history"), + (s.sy_home.join(SERVER_CONFIG), "server config"), + (s.sy_home.join(ROUTING_LOG), "usage history"), ] { let verb = if removing || path.exists() { "Preserve" @@ -776,7 +776,7 @@ fn build(s: &Settings) -> Result<(), String> { /// Checks the server config that the install keeps, or the bundled one on a first install, and /// returns the API key variables that have no saved key. fn check_server_config(s: &Settings, require_route: bool) -> Result, String> { - let config = s.sy_home.join("composite.toml"); + let config = s.sy_home.join(SERVER_CONFIG); let existing = present(&config)?; let staged = tempfile::NamedTempFile::new().map_err(err)?; let checked = if existing { @@ -830,11 +830,11 @@ fn replace_installation(s: &Settings, desktop_settings: Option<&str>) -> Result< let bytes = fs::read(s.release(binary)).map_err(err)?; write(&s.binary(binary), &bytes, true)?; } - write_once(&s.sy_home.join("composite.toml"), BUNDLED_CONFIG)?; + write_once(&s.sy_home.join(SERVER_CONFIG), BUNDLED_CONFIG)?; if let Some(text) = desktop_settings { write(&s.desktop_settings(), text.as_bytes(), false)?; } else { - write_once(&s.desktop_settings(), &desktop_config(s))?; + write_once(&s.desktop_settings(), &desktop_config(s)?)?; } for record in [s.sy_home.join("install.toml"), s.install_record()] { write(&record, metadata.as_bytes(), false)?; @@ -944,9 +944,9 @@ fn reconcile_desktop_port(s: &Settings) -> Result, String> { let old_port = if previous.exists() { Settings::load(&previous)?.port } else { - 4123 + DEFAULT_PORT }; - let expected = format!("http://127.0.0.1:{old_port}"); + let expected = config::server_url(old_port); let current = document .get("server_url") .map(|value| { @@ -971,7 +971,7 @@ fn reconcile_desktop_port(s: &Settings) -> Result, String> { if current.is_some_and(|url| url != expected || s.port == old_port) { return Ok(None); } - document["server_url"] = toml_edit::value(format!("http://127.0.0.1:{}", s.port)); + document["server_url"] = toml_edit::value(config::server_url(s.port)); let updated = document.to_string(); Ok((updated != text).then_some(updated)) } @@ -980,7 +980,7 @@ fn reconcile_desktop_port(s: &Settings) -> Result, String> { // config or, on a new install, the bundled one. A missing route returns `None` here; // check_public_route rejects it later with the list of valid route IDs. fn route_context_window(s: &Settings) -> Result, String> { - let path = s.sy_home.join("composite.toml"); + let path = s.sy_home.join(SERVER_CONFIG); let text = read_optional(&path).map_err(|e| format!("Read {}: {e}", path.display()))?; let document: toml::Value = toml::from_str(text.as_deref().unwrap_or(BUNDLED_CONFIG)) .map_err(|e| format!("Parse {}: {e}", path.display()))?; @@ -1051,34 +1051,16 @@ fn merge_codex_profile( Ok(document.to_string()) } -fn desktop_config(s: &Settings) -> String { - let mut config = format!( - "server_url = {:?}\n\ - routing_log = {}\n\ - config_file = {}\n\ - launchd_label = {:?}\n\ - refresh_seconds = 30\n\ - \n\ - # Savings compare recorded costs with answer tokens priced at baseline_model rates.\n\ - # The app uses bundled rates unless pricing_file is set.\n\ - baseline_model = \"gpt-6-sol\"\n", - format!("http://127.0.0.1:{}", s.port), - toml_string(&s.sy_home.join("routing.jsonl").to_string_lossy()), - toml_string(&s.sy_home.join("composite.toml").to_string_lossy()), - SERVER, - ); +// The installer and app share defaults so generated settings keep the selected directory and port. +fn desktop_config(s: &Settings) -> Result { + let mut config = Config::for_directory(&s.sy_home, s.port); let prices = s.prices_file(); if prices.exists() { - config.push_str(&format!( - "pricing_file = {}\n", - toml_string(&prices.to_string_lossy()) - )); + config.prices_file = Some(prices); } - config -} -fn toml_string(value: &str) -> String { - toml::Value::String(value.into()).to_string() + toml::to_string(&config).map_err(err) } + #[cfg(any(target_os = "macos", windows))] fn xml(value: &str) -> String { value diff --git a/crates/switchyard-desktop/src/install/macos.rs b/crates/switchyard-desktop/src/install/macos.rs index 7fc8345221..888f703076 100644 --- a/crates/switchyard-desktop/src/install/macos.rs +++ b/crates/switchyard-desktop/src/install/macos.rs @@ -11,7 +11,9 @@ use std::{ time::Duration, }; -use super::{SERVER, Settings, err, present, remove, run, write, xml}; +use crate::config::SERVER_LABEL as SERVER; + +use super::{Settings, err, present, remove, run, write, xml}; const DESKTOP: &str = "com.nvidia.switchyard.desktop"; diff --git a/crates/switchyard-desktop/src/lib.rs b/crates/switchyard-desktop/src/lib.rs index be41a3ff19..0902bf4507 100644 --- a/crates/switchyard-desktop/src/lib.rs +++ b/crates/switchyard-desktop/src/lib.rs @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The installer that `cargo desktop`, the installed `switchyard-desktop-install` helper, and the -//! desktop app share. +//! Desktop settings, server checks, and installation support. +pub mod config; +pub mod health; pub mod install; diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs new file mode 100644 index 0000000000..10c3e58a84 --- /dev/null +++ b/crates/switchyard-desktop/src/main.rs @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The desktop app reports a local Switchyard server's recorded usage. +//! +//! It shows recorded traffic and compares list-price estimates with the +//! configured baseline model. + +use switchyard_desktop::{config, health}; +mod history; +mod pricing; +mod rollup; +mod summary; + +use std::path::PathBuf; +use std::process::ExitCode; + +use config::Config; + +const USAGE: &str = "\ +Usage: switchyard-desktop [--print | --validate-toml] [FILE] + +This app prints the usage that a local Switchyard server recorded. + + FILE The app reads settings from FILE. + --print Print the current summary and exit. This is the default. + --validate-toml Parse FILE as TOML and exit. + +Without FILE, the app uses bundled installation metadata to find desktop.toml. +Without that metadata, it uses $SY_HOME/desktop.toml, or ~/.switchyard/desktop.toml +when SY_HOME is unset. + +Missing settings use the directory and port from adjacent install.toml, or the +settings file's directory and port 4123 when that metadata is absent. +The default server URL includes the port, such as http://127.0.0.1:4123. +A configured HTTP URL without a port uses port 80. + +Savings compare spending on all calls with the cost of the answers at +baseline_model's bundled rates or the table selected by pricing_file. +Recorded spending remains visible when savings are unknown."; + +fn main() -> ExitCode { + let mut settings = None; + let mut validate_toml = false; + for arg in std::env::args().skip(1) { + match arg.as_str() { + "-h" | "--help" => { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } + "--print" => {} + "--validate-toml" => validate_toml = true, + _ if arg.starts_with('-') => { + eprintln!("switchyard-desktop: unknown option {arg}\n\n{USAGE}"); + return ExitCode::FAILURE; + } + _ => settings = Some(PathBuf::from(arg)), + } + } + + if validate_toml { + let Some(path) = settings else { + eprintln!("switchyard-desktop: --validate-toml requires a file path"); + return ExitCode::FAILURE; + }; + if let Err(error) = config::validate_toml_file(&path) { + eprintln!("switchyard-desktop: {error}"); + return ExitCode::FAILURE; + } + return ExitCode::SUCCESS; + } + + let settings = match settings.map(Ok).unwrap_or_else(Config::default_path) { + Ok(path) => path, + Err(error) => { + eprintln!("switchyard-desktop: {error}"); + return ExitCode::FAILURE; + } + }; + let config = match Config::load(&settings) { + Ok(config) => config, + Err(error) => { + eprintln!("switchyard-desktop: {error}"); + return ExitCode::FAILURE; + } + }; + let usage = match rollup::Reader::default() + .read(&config.routing_log, chrono::Local::now().date_naive()) + { + Ok(usage) => usage, + Err(error) => { + eprintln!( + "Usage unavailable. Could not read {}: {error}", + config.routing_log.display() + ); + return ExitCode::FAILURE; + } + }; + let rates = match pricing::load(config.prices_file.as_deref()) { + Ok(rates) => rates, + Err(error) => { + eprintln!("switchyard-desktop: {error}"); + return ExitCode::FAILURE; + } + }; + let status = health::probe(&config.server_url); + for row in summary::build(status, &usage, &config, &rates) { + println!("{row}"); + } + ExitCode::SUCCESS +} diff --git a/crates/switchyard-desktop/src/pricing.rs b/crates/switchyard-desktop/src/pricing.rs new file mode 100644 index 0000000000..2a8171d47d --- /dev/null +++ b/crates/switchyard-desktop/src/pricing.rs @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module compares the cost that the server recorded with the cost of the same answers on +//! the baseline model. The server prices each call when it finishes. The app uses bundled +//! rates or a configured override only to price the baseline. + +use std::path::Path; + +use serde::Serialize; +use switchyard_costs::Pricing; + +use crate::rollup::Totals; + +/// This function loads bundled rates when no override is selected. +/// An explicit file replaces the entire table; this function returns an error if it cannot load it. +pub fn load(path: Option<&Path>) -> Result { + match path { + Some(path) => Pricing::load(path), + None => Pricing::bundled(), + } +} + +/// Estimate stores one period's recorded cost and the baseline model's cost for its answers. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct Estimate { + /// This field stores the recorded cost of every call, including routing calls. It is + /// `None` when any call has no recorded cost. + pub actual: Option, + /// This field adds up the calls that have a recorded cost. + pub known: f64, + /// This field counts calls that the server recorded without a cost. + pub unknown_calls: u64, + /// This field prices the answer calls at the baseline model's rates. It is `None` when + /// the selected price table has no rates for the baseline model. + pub baseline: Option, + /// This field counts answer calls recorded without token counts. The baseline cannot + /// price them, so they hide the savings. + pub uncounted_answers: u64, +} + +impl Estimate { + pub fn new(totals: &Totals, rates: &Pricing, baseline_model: &str) -> Self { + let cost = totals.cost(); + // Routing calls would not exist without Switchyard, so the baseline prices only answers. + let baseline = rates + .contains(baseline_model) + .then(|| { + totals.routed.values().try_fold(0.0, |sum, tokens| { + Some(sum + rates.estimate(baseline_model, &tokens.usage())?.usd) + }) + }) + .flatten(); + Self { + actual: cost.total_usd, + known: cost.known_usd, + unknown_calls: cost.unknown_calls, + baseline, + uncounted_answers: totals.routed.values().map(|tokens| tokens.uncounted).sum(), + } + } + + /// This function returns the savings when every call has a cost, every answer has token + /// counts, and the baseline has rates. + pub fn savings(&self) -> Option { + if self.uncounted_answers > 0 { + return None; + } + Some(Savings { + actual: self.actual?, + baseline: self.baseline?, + }) + } +} + +/// Savings stores a period's recorded cost and the baseline cost of the same answers. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Savings { + pub actual: f64, + pub baseline: f64, +} + +impl Savings { + /// This function returns estimated dollars saved; routing overhead can make the value negative. + pub fn saved(&self) -> f64 { + self.baseline - self.actual + } + + /// This function returns the percentage saved, or `None` when the baseline cost is zero. + pub fn percent(&self) -> Option { + (self.baseline > 0.0).then(|| self.saved() / self.baseline * 100.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rollup::ModelTokens; + use switchyard_costs::{CostSource, RecordedCost}; + + fn tokens(input: u64, cached: u64, write: u64, output: u64, usd: Option) -> ModelTokens { + let mut tokens = ModelTokens { + requests: 1, + input, + cached_input: cached, + cache_write: write, + output, + ..ModelTokens::default() + }; + tokens.cost.add(usd.map(|usd| RecordedCost { + usd, + source: CostSource::ProviderReported, + })); + tokens + } + + #[test] + fn savings_use_recorded_costs_and_the_baseline_rates() { + let rates = Pricing::parse( + br#"{"baseline":{"input":1,"cache_read":0.1,"cache_write":2,"output":10}}"#, + ) + .expect("rates"); + let mut totals = Totals::default(); + let empty = Estimate::new(&totals, &rates, "baseline"); + assert_eq!(empty.savings().map(|s| s.saved()), Some(0.0)); + assert!(empty.savings().and_then(|s| s.percent()).is_none()); + + // The answer's recorded cost is used as is; the baseline prices its tokens: + // 1M input at $1, 1M cache reads at $0.10, 1M cache writes at $2, 0.1M output at $10. + totals.routed.insert( + "cheap".into(), + tokens(1_000_000, 1_000_000, 1_000_000, 100_000, Some(0.5)), + ); + let routed = Estimate::new(&totals, &rates, "baseline") + .savings() + .expect("savings"); + assert_eq!(routed.actual, 0.5); + assert!((routed.baseline - 4.1).abs() < 1e-9); + + // Routing calls add to the recorded cost but not to the baseline. + totals + .classifier + .insert("judge".into(), tokens(10, 0, 0, 1, Some(5.0))); + let overhead = Estimate::new(&totals, &rates, "baseline") + .savings() + .expect("savings"); + assert_eq!(overhead.baseline, routed.baseline); + assert!(overhead.saved() < 0.0); + + // An answer with a charge but no token counts cannot be priced on the baseline. + let mut uncounted = tokens(0, 0, 0, 0, Some(1.0)); + uncounted.uncounted = 1; + totals.routed.insert("uncounted".into(), uncounted); + let hidden = Estimate::new(&totals, &rates, "baseline"); + assert_eq!((hidden.actual, hidden.uncounted_answers), (Some(6.5), 1)); + assert!(hidden.savings().is_none()); + totals.routed.remove("uncounted"); + + // A call without a recorded cost hides the savings but keeps the known subtotal. + totals.routed.insert("old".into(), tokens(1, 0, 0, 1, None)); + let partial = Estimate::new(&totals, &rates, "baseline"); + assert_eq!( + (partial.actual, partial.known, partial.unknown_calls), + (None, 5.5, 1) + ); + assert!(partial.savings().is_none()); + assert!( + Estimate::new(&Totals::default(), &rates, "missing") + .baseline + .is_none() + ); + + // One-hour cache writes need the baseline's one-hour rate. + let mut long = tokens(0, 0, 1_000_000, 0, Some(1.0)); + long.cache_write_1h = 1_000_000; + let long = Totals { + routed: [("long".into(), long)].into(), + ..Totals::default() + }; + assert!(Estimate::new(&long, &rates, "baseline").baseline.is_none()); + let with_1h = Pricing::parse( + br#"{"baseline":{"input":1,"cache_read":0.1,"cache_write":2,"cache_write_1h":4,"output":10}}"#, + ) + .expect("rates"); + assert_eq!( + Estimate::new(&long, &with_1h, "baseline").baseline, + Some(4.0) + ); + } +} diff --git a/crates/switchyard-desktop/src/rollup.rs b/crates/switchyard-desktop/src/rollup.rs new file mode 100644 index 0000000000..222ff4a562 --- /dev/null +++ b/crates/switchyard-desktop/src/rollup.rs @@ -0,0 +1,456 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module totals recorded model calls from the retained routing log. + +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{BufRead, BufReader, Seek, SeekFrom}; +use std::path::Path; + +use crate::history::Entry; +use chrono::{DateTime, Local, NaiveDate}; +use serde::{Deserialize, Serialize}; +use switchyard_costs::CostTotals; + +/// CLASSIFIER_TIER matches the tier recorded for classifier and judge calls. +const CLASSIFIER_TIER: &str = "classifier"; + +/// ModelTokens stores recorded token counts and costs for one model. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ModelTokens { + pub requests: u64, + /// This field counts input tokens that were neither read from nor written to a cache. + pub input: u64, + /// This field counts input tokens served from the provider cache. + pub cached_input: u64, + /// This field counts input tokens written to the provider cache. + pub cache_write: u64, + /// This field counts the cache writes that use the one-hour rate. + pub cache_write_1h: u64, + /// This field counts generated tokens, including reasoning. + pub output: u64, + /// This field adds up the costs that the server recorded for these calls. + pub cost: CostTotals, + /// This field counts calls recorded without token counts, which cannot be priced. + pub uncounted: u64, +} + +impl ModelTokens { + /// This function returns the recorded token total, including cached reads. + pub fn total(&self) -> u64 { + self.input + self.cached_input + self.cache_write + self.output + } + + pub fn add(&mut self, other: &Self) { + self.requests += other.requests; + self.input += other.input; + self.cached_input += other.cached_input; + self.cache_write += other.cache_write; + self.cache_write_1h += other.cache_write_1h; + self.output += other.output; + self.cost.merge(&other.cost); + self.uncounted += other.uncounted; + } + + /// This function returns the token counts as usage that a price table can price. + pub fn usage(&self) -> switchyard_protocol::Usage { + let mut cache = switchyard_protocol::Usage::cache_details( + Some(self.cached_input), + Some(self.cache_write), + ); + if let Some(cache) = cache.as_mut() { + cache.cache_creation_1h_input_tokens = Some(self.cache_write_1h); + } + switchyard_protocol::Usage { + input_tokens: Some(self.input), + output_tokens: Some(self.output), + cache, + ..Default::default() + } + } +} + +/// Totals separates caller-facing calls from classifier calls for a period. +/// +/// The split matters for savings: without Switchyard the `routed` calls would +/// still have happened, but the `classifier` calls would not exist at all. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Totals { + /// This field groups caller-facing calls by the model that answered. + pub routed: BTreeMap, + /// This field groups classifier and judge calls by model. + pub classifier: BTreeMap, + /// Each route includes its answer calls and routing overhead. + pub routes: BTreeMap, +} + +impl Totals { + fn add(&mut self, record: &Entry) { + let bucket = if record.tier == CLASSIFIER_TIER { + &mut self.classifier + } else { + &mut self.routed + }; + let tokens = ModelTokens::from(record); + bucket.entry(record.model.clone()).or_default().add(&tokens); + self.routes + .entry(record.route_id.clone()) + .or_default() + .add(&tokens); + } + + fn merge(&mut self, other: &Self) { + for (own, incoming) in [ + (&mut self.routed, &other.routed), + (&mut self.classifier, &other.classifier), + (&mut self.routes, &other.routes), + ] { + for (model, tokens) in incoming { + own.entry(model.clone()).or_default().add(tokens); + } + } + } + + /// This function returns the number of caller-facing calls. + pub fn requests(&self) -> u64 { + self.routed.values().map(|tokens| tokens.requests).sum() + } + + /// This function totals recorded tokens, including routing overhead. + pub fn tokens(&self) -> u64 { + self.routed + .values() + .chain(self.classifier.values()) + .map(ModelTokens::total) + .sum() + } + + /// This function adds up the recorded cost of every call, including routing overhead. + pub fn cost(&self) -> CostTotals { + let mut cost = CostTotals::default(); + for tokens in self.routed.values().chain(self.classifier.values()) { + cost.merge(&tokens.cost); + } + cost + } + + /// This function returns true when neither group contains recorded calls. + pub fn is_empty(&self) -> bool { + self.routed.is_empty() && self.classifier.is_empty() + } +} + +/// Usage stores totals from the retained log and seven local calendar days. +#[derive(Clone, Debug, Default, Serialize)] +pub struct Usage { + pub today: Totals, + pub week: Totals, + pub all: Totals, + pub days: BTreeMap, +} + +impl From<&Entry> for ModelTokens { + fn from(record: &Entry) -> Self { + let mut cost = CostTotals::default(); + cost.add(record.cost); + Self { + requests: 1, + // The recorded input includes cache reads and writes; output includes reasoning. + input: record + .prompt_tokens + .saturating_sub(record.cached_tokens) + .saturating_sub(record.cache_creation_tokens), + cached_input: record.cached_tokens, + cache_write: record.cache_creation_tokens, + cache_write_1h: record.cache_creation_1h_tokens, + output: record.completion_tokens, + cost, + uncounted: u64::from( + record.completion_tokens == 0 + || record.prompt_tokens == 0 + || record + .prompt_tokens + .checked_sub(record.cached_tokens) + .and_then(|input| input.checked_sub(record.cache_creation_tokens)) + .is_none() + || record.cache_creation_1h_tokens > record.cache_creation_tokens, + ), + } + } +} + +/// Reader keeps model and route totals without retaining individual calls. +/// Each read consumes only newly completed log lines. +#[derive(Default)] +pub struct Reader { + file_id: Option<(u64, u64)>, + offset: u64, + days: BTreeMap, + all: Totals, +} + +impl Reader { + /// A missing log has no usage. Replacement or truncation starts fresh. + /// An invalid complete record returns an error because skipping it would understate usage. + pub fn read(&mut self, path: &Path, today: NaiveDate) -> std::io::Result { + let mut file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + *self = Self::default(); + return Ok(Usage::default()); + } + Err(error) => return Err(error), + }; + let length = file.metadata()?.len(); + let id = file_id(&file)?; + if length < self.offset || self.file_id != Some(id) { + self.offset = 0; + self.days.clear(); + self.all = Totals::default(); + } + self.file_id = Some(id); + file.seek(SeekFrom::Start(self.offset))?; + + let week_start = today - chrono::Duration::days(6); + self.days.retain(|day, _| *day >= week_start); + let mut reader = BufReader::new(file); + let mut line = Vec::new(); + loop { + line.clear(); + let bytes = reader.read_until(b'\n', &mut line)?; + // The offset stays before an unfinished line so the next refresh retries it. + if line.last() != Some(&b'\n') { + break; + } + let invalid = |message| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid routing record at byte {}: {message}", self.offset), + ) + }; + let record: Entry = + serde_json::from_slice(&line).map_err(|error| invalid(error.to_string()))?; + if record.model.trim().is_empty() { + return Err(invalid("model must not be empty".into())); + } + let ts = DateTime::parse_from_rfc3339(&record.ts) + .map_err(|error| invalid(format!("invalid timestamp: {error}")))?; + self.offset += bytes as u64; + let day = ts.with_timezone(&Local).date_naive(); + self.all.add(&record); + if day >= week_start { + self.days.entry(day).or_default().add(&record); + } + } + let mut usage = Usage { + all: self.all.clone(), + ..Usage::default() + }; + for (day, totals) in &self.days { + if *day == today { + usage.today = totals.clone(); + } + if *day <= today { + usage.week.merge(totals); + usage.days.insert(*day, totals.clone()); + } + } + Ok(usage) + } +} + +/// This function returns the device and inode numbers that identify an open file. +/// A replaced log has a new inode, so the reader starts its totals over. +#[cfg(unix)] +fn file_id(file: &File) -> std::io::Result<(u64, u64)> { + use std::os::unix::fs::MetadataExt; + let metadata = file.metadata()?; + Ok((metadata.dev(), metadata.ino())) +} + +/// This function returns the volume serial number and file index that identify an open file. +/// NTFS can give a recreated file the old creation time, so creation time cannot detect a +/// replaced log. +#[cfg(windows)] +fn file_id(file: &File) -> std::io::Result<(u64, u64)> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + let mut info = BY_HANDLE_FILE_INFORMATION::default(); + // The handle stays open for the call because `file` borrows it. + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 { + return Err(std::io::Error::last_os_error()); + } + let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow); + Ok((u64::from(info.dwVolumeSerialNumber), index)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn date(text: &str) -> NaiveDate { + text.parse().expect("valid date") + } + + /// This function builds a log line stamped at a local time, mirroring how the server + /// writes timestamps for events that happened in the local day. + fn line(local_ts: &str, model: &str, tier: &str, prompt: u64, completion: u64) -> String { + let offset = Local::now().offset().to_string(); + format!( + r#"{{"ts":"{local_ts}{offset}","model":"{model}","tier":"{tier}","prompt_tokens":{prompt},"cached_tokens":0,"completion_tokens":{completion},"reasoning_tokens":0}}"# + ) + } + + fn log(contents: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("routing.jsonl"); + std::fs::write(&path, contents).expect("write log"); + (dir, path) + } + + #[test] + fn separates_cache_reads_from_full_price_input() { + let record: Entry = serde_json::from_str( + r#"{"ts":"2026-09-28T10:00:00.000Z","model":"m","prompt_tokens":1000,"cached_tokens":600,"completion_tokens":50,"reasoning_tokens":25}"#, + ) + .expect("parse record"); + let tokens = ModelTokens::from(&record); + assert_eq!(tokens.input, 400); + assert_eq!(tokens.cached_input, 600); + assert_eq!(tokens.output, 50); + assert_eq!(tokens.total(), 1_050); + } + + #[test] + fn baseline_usage_rejects_zero_missing_and_inconsistent_counts() { + for (usage, uncounted) in [ + (r#""prompt_tokens":0,"completion_tokens":0"#, 1), + (r#""prompt_tokens":0,"completion_tokens":1"#, 1), + (r#""prompt_tokens":10,"completion_tokens":0"#, 1), + (r#""prompt_tokens":10,"completion_tokens":1"#, 0), + (r#""prompt_tokens":10"#, 1), + (r#""completion_tokens":10"#, 1), + ( + r#""prompt_tokens":10,"completion_tokens":1,"cached_tokens":11"#, + 1, + ), + ( + r#""prompt_tokens":10,"completion_tokens":1,"cached_tokens":6,"cache_creation_tokens":5"#, + 1, + ), + ( + r#""prompt_tokens":10,"completion_tokens":1,"cache_creation_tokens":4,"cache_creation_1h_tokens":5"#, + 1, + ), + ] { + let record: Entry = serde_json::from_str(&format!( + r#"{{"ts":"2026-09-28T10:00:00Z","model":"m",{usage}}}"# + )) + .expect("record"); + assert_eq!(ModelTokens::from(&record).uncounted, uncounted, "{usage}"); + } + } + + #[test] + fn reads_appends_once_and_retries_an_unfinished_line() { + use std::io::Write; + + let first = line("2026-09-28T10:00:00.000", "luna", "", 100, 10); + let second = line("2026-09-28T10:00:01.000", "terra", "classifier", 30, 1); + let (_dir, path) = log(&format!("{first}\n")); + let today = date("2026-09-28"); + let mut reader = Reader::default(); + assert_eq!(reader.read(&path, today).expect("read").today.tokens(), 110); + assert_eq!( + reader.read(&path, today).expect("reread").today.tokens(), + 110 + ); + + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open"); + let split = second.len() / 2; + file.write_all(&second.as_bytes()[..split]) + .expect("partial append"); + assert_eq!( + reader + .read(&path, today) + .expect("partial read") + .today + .tokens(), + 110 + ); + writeln!(file, "{}", &second[split..]).expect("finish append"); + let usage = reader.read(&path, today).expect("read append"); + assert_eq!(usage.today.tokens(), 141); + assert_eq!(usage.today.requests(), 1); + assert_eq!(usage.today.classifier["terra"].requests, 1); + } + + // The old calls exceed the detail limit, so bounded history cannot supply these totals. + #[test] + fn totals_the_full_log_by_model_route_and_local_day() { + let old = line("2026-08-01T12:00:00.000", "older-model", "", 100, 10); + let answer = line("2026-09-28T12:00:00.000", "answer", "", 50, 5); + let judge = line("2026-09-28T12:00:01.000", "answer", "classifier", 20, 2); + let with_route = + |text: &str| text.replace("\"model\":", "\"route_id\":\"router\",\"model\":"); + let (_dir, path) = log(&format!( + "{}{}\n{}\n", + format!("{old}\n").repeat(5_001), + with_route(&answer), + with_route(&judge), + )); + let mut reader = Reader::default(); + let usage = reader.read(&path, date("2026-09-28")).expect("read"); + assert_eq!(usage.all.requests(), 5_002); + assert_eq!(usage.all.tokens(), 550_187); + assert_eq!(usage.week.tokens(), 77); + assert_eq!(usage.today.routes["router"].requests, 2); + assert_eq!(usage.today.routes["router"].total(), 77); + assert_eq!(usage.days[&date("2026-09-28")], usage.today); + assert_eq!( + reader + .read(&path, date("2026-09-29")) + .expect("next day") + .all, + usage.all + ); + } + + #[test] + fn resets_totals_when_the_log_is_replaced_or_truncated() { + let (_dir, path) = log(&format!( + "{}\n{}\n", + line("2026-09-28T10:00:00.000", "luna", "", 100, 10), + line("2026-09-28T10:00:01.000", "luna", "", 100, 10), + )); + let mut reader = Reader::default(); + let today = date("2026-09-28"); + assert_eq!(reader.read(&path, today).expect("read").today.requests(), 2); + let replacement = format!("{}\n", line("2026-09-28T11:00:00.000", "sol", "", 200, 20)); + std::fs::write(&path, &replacement).expect("truncate"); + let usage = reader.read(&path, today).expect("read truncated"); + assert_eq!(usage.today.requests(), 1); + assert_eq!(usage.today.tokens(), 220); + assert_eq!(usage.all.tokens(), 220); + + // The new first record has the same length as the old log but different counts, + // so a reader that kept its offset would skip it and keep the old 220 tokens. + std::fs::rename(&path, path.with_extension("old")).expect("rotate"); + let first = format!("{}\n", line("2026-09-28T11:00:00.000", "sol", "", 400, 40)); + assert_eq!(first.len(), replacement.len()); + std::fs::write(&path, first + &replacement.repeat(2)).expect("replace"); + let usage = reader.read(&path, today).expect("read replacement"); + assert_eq!(usage.today.requests(), 3); + assert_eq!(usage.today.tokens(), 880); + assert_eq!(usage.all.tokens(), 880); + std::fs::remove_file(&path).expect("remove log"); + let absent = reader.read(&path, today).expect("missing log"); + assert!(absent.today.is_empty() && absent.week.is_empty() && absent.all.is_empty()); + } +} diff --git a/crates/switchyard-desktop/src/summary.rs b/crates/switchyard-desktop/src/summary.rs new file mode 100644 index 0000000000..7fa20723cd --- /dev/null +++ b/crates/switchyard-desktop/src/summary.rs @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module turns totals and recorded costs into usage summary rows. + +use switchyard_costs::Pricing; + +use crate::pricing::{Estimate, Savings}; +use crate::rollup::{Totals, Usage}; +use switchyard_desktop::config::Config; +use switchyard_desktop::health::ServerStatus; + +/// MAX_MODEL_ROWS limits the number of models shown in the usage summary. +const MAX_MODEL_ROWS: usize = 5; + +/// This function builds the menu body for the current day and trailing week. +pub fn build(status: ServerStatus, usage: &Usage, config: &Config, rates: &Pricing) -> Vec { + let state = match status { + ServerStatus::Running => "running", + ServerStatus::Stopped => "not responding", + }; + let mut rows = vec![ + format!("Server: {state} · {}", config.server_url), + String::new(), + ]; + + if usage.week.is_empty() { + rows.push(if usage.all.is_empty() { + "No requests recorded yet".into() + } else { + "No requests recorded in the last 7 days".into() + }); + return rows; + } + + rows.extend(period("Today", &usage.today, config, rates)); + rows.push(String::new()); + rows.extend(period("This week", &usage.week, config, rates)); + + let models = model_shares(&usage.week); + if !models.is_empty() { + rows.push(String::new()); + rows.push("This week by model".into()); + rows.extend(models); + } + + let unknown = usage.week.cost().unknown_calls; + let uncounted: u64 = usage + .week + .routed + .values() + .map(|tokens| tokens.uncounted) + .sum(); + if !rates.contains(&config.baseline_model) { + rows.push(String::new()); + rows.push(match config.prices_file.as_ref() { + Some(path) => format!("Add {} to {} to see savings", config.baseline_model, path.display()), + None => format!("Bundled prices have no rates for {}. Set pricing_file to a custom table to see savings.", config.baseline_model), + }); + } else if unknown > 0 { + rows.push(String::new()); + rows.push(format!( + "Savings hidden for this week: {unknown} {} no recorded cost", + if unknown == 1 { + "call has" + } else { + "calls have" + } + )); + } else if uncounted > 0 { + rows.push(String::new()); + let answers = if uncounted == 1 { + "answer has" + } else { + "answers have" + }; + rows.push(format!( + "Savings hidden for this week: {uncounted} {answers} no token counts" + )); + } + rows +} + +fn period(name: &str, totals: &Totals, config: &Config, rates: &Pricing) -> Vec { + let requests = totals.requests(); + let mut rows = vec![format!( + "{name} — {requests} request{} · {} tokens", + if requests == 1 { "" } else { "s" }, + tokens(totals.tokens()) + )]; + let estimate = Estimate::new(totals, rates, &config.baseline_model); + rows.push(match estimate.actual { + Some(actual) => format!(" Spent {}", money(actual)), + None => format!( + " Known spending {} · total unknown ({} {} no recorded cost)", + money(estimate.known), + estimate.unknown_calls, + if estimate.unknown_calls == 1 { + "call has" + } else { + "calls have" + }, + ), + }); + if let Some(savings) = estimate.savings() { + rows.push(format!(" Saved {}", saved(&savings))); + } + rows +} + +fn saved(savings: &Savings) -> String { + let amount = money(savings.saved()); + match savings.percent() { + Some(percent) => format!("{amount} ({percent:.0}% of {})", money(savings.baseline)), + None => amount, + } +} + +fn model_shares(week: &Totals) -> Vec { + let total: u64 = week.routed.values().map(|tokens| tokens.total()).sum(); + if total == 0 { + return Vec::new(); + } + let mut models: Vec<_> = week + .routed + .iter() + .map(|(model, tokens)| (model, tokens.total())) + .collect(); + // Models appear by decreasing share; the model ID breaks ties for a stable order. + models.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(right.0))); + models + .into_iter() + .take(MAX_MODEL_ROWS) + .map(|(model, count)| format!(" {model} — {:.0}%", count as f64 / total as f64 * 100.0)) + .collect() +} + +/// This function formats a token count compactly, since exact totals are not useful here. +fn tokens(value: u64) -> String { + match value { + 0..=9_999 => value.to_string(), + 10_000..=999_999 => format!("{:.0}K", value as f64 / 1_000.0), + 1_000_000..=999_999_999 => format!("{:.1}M", value as f64 / 1_000_000.0), + _ => format!("{:.1}B", value as f64 / 1_000_000_000.0), + } +} + +/// This function formats dollars, keeping the sign readable when routing cost more than it saved. +fn money(value: f64) -> String { + if value < 0.0 { + format!("-${:.2}", -value) + } else { + format!("${value:.2}") + } +} diff --git a/crates/switchyard-desktop/tests/desktop.rs b/crates/switchyard-desktop/tests/desktop.rs index 3c0cc1c84a..71e560b5b3 100644 --- a/crates/switchyard-desktop/tests/desktop.rs +++ b/crates/switchyard-desktop/tests/desktop.rs @@ -302,6 +302,12 @@ esac &settings, &("baseline_model = 'custom'\n".to_owned() + &url(f.s.port)), ); + let app = + switchyard_desktop::config::Config::load(&settings).expect("partial app settings"); + assert_eq!(app.routing_log, sy_home.join("routing.jsonl")); + assert!(app.prices_file.is_none()); + assert_eq!(app.config_file, sy_home.join("composite.toml")); + assert_eq!(app.server_url, format!("http://127.0.0.1:{}", f.s.port)); let first = read(&f.profile()); let profile = toml(&f.profile()); assert_eq!(profile["model"].as_str(), Some(f.s.model.as_str())); diff --git a/crates/switchyard-desktop/tests/print.rs b/crates/switchyard-desktop/tests/print.rs new file mode 100644 index 0000000000..b12b709779 --- /dev/null +++ b/crates/switchyard-desktop/tests/print.rs @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// An unreadable log must fail instead of reporting that no requests were recorded. +#[test] +fn unreadable_usage_fails_without_an_empty_summary() { + let home = tempfile::tempdir().expect("fixture"); + let settings = home.path().join("desktop.toml"); + std::fs::write(&settings, format!("routing_log={:?}\n", home.path())).expect("settings"); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_switchyard-desktop")) + .arg("--print") + .arg(&settings) + .env("HOME", home.path()) + .output() + .expect("print"); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("Usage unavailable")); + assert_eq!(std::fs::read_dir(home.path()).expect("files").count(), 1); +} + +#[test] +fn print_uses_bundled_prices_and_requires_valid_explicit_overrides() +-> Result<(), Box> { + let dir = tempfile::tempdir().expect("fixture"); + let settings = dir.path().join("desktop.toml"); + let log = dir.path().join("routing.jsonl"); + // Both token counts are positive so the fixture qualifies for a baseline estimate. + let record = serde_json::json!({ + "ts": chrono::Local::now().to_rfc3339(), + "model": "gpt-6-sol", "prompt_tokens": 1_000_000, "completion_tokens": 100_000, + "cost": {"usd": 0.1, "source": "provider_reported"} + }); + std::fs::write(&log, format!("{record}\n")).expect("log"); + let base = format!("server_url='http://127.0.0.1:1'\nrouting_log={log:?}\n"); + std::fs::write(&settings, &base).expect("settings"); + let print = || { + std::process::Command::new(env!("CARGO_BIN_EXE_switchyard-desktop")) + .arg("--print") + .arg(&settings) + .output() + .expect("print") + }; + let output = print(); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("Saved $2.90 (97% of $3.00)")); + assert!(!dir.path().join("custom-prices.toml").exists()); + // A Claude baseline must use bundled rates while keeping the saved charge. + std::fs::write( + &settings, + format!("{base}baseline_model='claude-opus-5-5'\n"), + )?; + let output = print(); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("Saved $5.90 (98% of $6.00)")); + for (name, text) in [ + ( + "custom-prices.json", + r#"{"gpt-6-sol":{"input":9,"output":0,"cache_read":0,"cache_write":0}}"#, + ), + ( + "custom-prices.toml", + "[\"gpt-6-sol\"]\ninput=9\noutput=0\ncache_read=0\ncache_write=0\n", + ), + ] { + let prices = dir.path().join(name); + std::fs::write(&settings, format!("{base}pricing_file={prices:?}\n")) + .expect("override settings"); + for invalid in [None, Some("not json")] { + if let Some(text) = invalid { + std::fs::write(&prices, text).expect("invalid file"); + } + let output = print(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("pricing file")); + } + std::fs::write(&prices, text).expect("override"); + let output = print(); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("Saved $8.90 (99% of $9.00)")); + // The omitted model distinguishes table replacement from merging bundled rates. + std::fs::write( + &settings, + format!("{base}pricing_file={prices:?}\nbaseline_model='gpt-6-luna'\n"), + ) + .expect("missing baseline"); + let output = print(); + assert!(output.status.success()); + assert!(!String::from_utf8_lossy(&output.stdout).contains("Saved")); + } + assert_eq!( + std::fs::read_to_string(&log).expect("unchanged log"), + format!("{record}\n") + ); + Ok(()) +} + +fn print(settings: &std::path::Path) -> std::process::Output { + std::process::Command::new(env!("CARGO_BIN_EXE_switchyard-desktop")) + .arg("--print") + .arg(settings) + .output() + .expect("print") +} + +fn record() -> serde_json::Value { + serde_json::json!({ + "ts": chrono::Utc::now().to_rfc3339(), + "model": "actual", + "prompt_tokens": 1_000_000, + "completion_tokens": 1_000_000, + "cost": {"usd": 1, "source": "provider_reported"} + }) +} + +#[test] +fn recorded_spending_stays_visible_when_savings_are_unavailable() { + let dir = tempfile::tempdir().expect("fixture"); + let settings = dir.path().join("desktop.toml"); + let log = dir.path().join("routing.jsonl"); + let prices = dir.path().join("prices.json"); + std::fs::write( + &settings, + format!("server_url='http://127.0.0.1:0'\nbaseline_model='baseline'\nrouting_log={:?}\npricing_file={:?}\n", log, prices), + ) + .expect("settings"); + let valid = record(); + let mut unknown = record(); + unknown.as_object_mut().expect("record").remove("cost"); + let mut uncounted = record(); + uncounted["prompt_tokens"] = 0.into(); + for (entries, rates, spending, savings) in [ + (vec![valid.clone()], true, "Spent $1.00", true), + ( + vec![valid.clone(), unknown], + true, + "Known spending $1.00 · total unknown (1 call has no recorded cost)", + false, + ), + (vec![valid], false, "Spent $1.00", false), + (vec![uncounted], true, "Spent $1.00", false), + ] { + let text = entries + .iter() + .map(|entry| format!("{entry}\n")) + .collect::(); + std::fs::write(&log, text).expect("log"); + std::fs::write( + &prices, + if rates { + r#"{"baseline":{"input":10,"output":10,"cache_read":0,"cache_write":0}}"# + } else { + "{}" + }, + ) + .expect("prices"); + let output = print(&settings); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 output"); + assert!(stdout.contains(spending), "{stdout}"); + assert_eq!(stdout.contains("Saved "), savings, "{stdout}"); + if savings { + assert!(stdout.contains("Saved $19.00 (95% of $20.00)"), "{stdout}"); + } + } +} + +#[test] +fn invalid_complete_records_fail_before_health_checks_or_summary_output() { + let dir = tempfile::tempdir().expect("fixture"); + let settings = dir.path().join("desktop.toml"); + let log = dir.path().join("routing.jsonl"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener"); + listener.set_nonblocking(true).expect("nonblocking"); + let settings_text = format!( + "server_url='http://{}'\nrouting_log={:?}\n", + listener.local_addr().expect("address"), + log + ); + std::fs::write(&settings, &settings_text).expect("settings"); + let valid = record(); + let mut cost = valid.clone(); + cost["cost"]["usd"] = "damaged".into(); + let mut timestamp = valid.clone(); + timestamp["ts"] = "bad".into(); + let mut model = valid.clone(); + model["model"] = " ".into(); + let mut tokens = valid.clone(); + tokens["prompt_tokens"] = (-1).into(); + let mut id = valid.clone(); + id["session_id"] = 7.into(); + for text in [ + format!("{cost}\n"), + format!("{valid}\nnot-json\n"), + format!("{valid}\n{cost}\n"), + format!("{valid}\n{timestamp}\n"), + format!("{valid}\n{model}\n"), + format!("{valid}\n{tokens}\n"), + format!("{valid}\n{id}\n"), + ] { + std::fs::write(&log, &text).expect("log"); + let output = print(&settings); + assert!(!output.status.success(), "accepted {text}"); + assert!(output.stdout.is_empty(), "printed an incomplete summary"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Usage unavailable"), "{stderr}"); + assert!( + stderr.contains("invalid routing record at byte"), + "{stderr}" + ); + assert_eq!( + listener.accept().expect_err("no health request").kind(), + std::io::ErrorKind::WouldBlock + ); + assert_eq!(std::fs::read_to_string(&log).expect("unchanged log"), text); + assert_eq!( + std::fs::read_to_string(&settings).expect("unchanged settings"), + settings_text + ); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 2); + } +} + +#[test] +fn sy_home_selects_default_settings_and_usage() { + let home = tempfile::tempdir().expect("fixture"); + let selected = home.path().join("custom-installation"); + std::fs::create_dir(&selected).expect("installation directory"); + std::fs::write( + selected.join("desktop.toml"), + "server_url='http://127.0.0.1:0'\n", + ) + .expect("settings"); + std::fs::write(selected.join("routing.jsonl"), format!("{}\n", record())).expect("log"); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_switchyard-desktop")) + .arg("--print") + .env("HOME", home.path()) + .env("SY_HOME", &selected) + .output() + .expect("print"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("Spent $1.00")); +} + +#[cfg(unix)] +#[test] +fn tilde_paths_fail_when_home_resolution_returns_a_relative_directory() { + let dir = tempfile::tempdir().expect("fixture"); + let settings = dir.path().join("desktop.toml"); + std::fs::write(&settings, "routing_log='~/routing.jsonl'\n").expect("settings"); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_switchyard-desktop")) + .arg("--print") + .arg(&settings) + .env("HOME", "relative") + .output() + .expect("print"); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("Could not find your home directory")); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 1); +}