Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 61 additions & 15 deletions crates/switchyard-desktop/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! The desktop window shows local server health and recorded usage.
//! The desktop app reports a local Switchyard server's recorded usage.
//!
//! The printed summary shows recorded traffic and compares list-price estimates with the
//! configured baseline model.

#![cfg_attr(windows, windows_subsystem = "windows")]

use switchyard_desktop::config;
use switchyard_desktop::{config, health, rollup};
#[cfg(any(target_os = "macos", windows))]
mod gui;
mod pricing;
mod summary;

use std::path::PathBuf;
use std::process::ExitCode;

use config::Config;

const USAGE: &str = "\
Usage: switchyard-desktop [--validate-toml] [FILE]
Usage: switchyard-desktop [--print | --validate-toml] [FILE]

This app opens a window with server status and recorded usage.
The desktop window supports macOS and Windows.
This app opens the desktop window. Use --print for a usage summary.

FILE The app reads settings from FILE.
--print Print the current summary and exit.
--validate-toml Parse FILE as TOML and exit.

Without FILE, the app uses bundled installation metadata to find desktop.toml.
Expand All @@ -30,7 +35,11 @@ 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.";
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 {
// A desktop launch may have no parent console, so attachment failure does not stop the app.
Expand All @@ -42,12 +51,14 @@ fn main() -> ExitCode {
};
let mut settings = None;
let mut validate_toml = false;
let mut print_only = false;
for arg in std::env::args().skip(1) {
match arg.as_str() {
"-h" | "--help" => {
println!("{USAGE}");
return ExitCode::SUCCESS;
}
"--print" => print_only = true,
"--validate-toml" => validate_toml = true,
_ if arg.starts_with('-') => {
eprintln!("switchyard-desktop: unknown option {arg}\n\n{USAGE}");
Expand Down Expand Up @@ -76,18 +87,53 @@ fn main() -> ExitCode {
return ExitCode::FAILURE;
}
};
#[cfg(any(target_os = "macos", windows))]
match gui::run(settings) {
Ok(()) => ExitCode::SUCCESS,
if !print_only {
#[cfg(any(target_os = "macos", windows))]
return match gui::run(settings) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("switchyard-desktop: {error}");
ExitCode::FAILURE
}
};
#[cfg(not(any(target_os = "macos", windows)))]
{
eprintln!(
"The desktop window supports macOS and Windows. Use --print for a usage summary."
);
return ExitCode::FAILURE;
}
}

let config = match Config::load(&settings) {
Ok(config) => config,
Err(error) => {
eprintln!("switchyard-desktop: {error}");
ExitCode::FAILURE
return ExitCode::FAILURE;
}
}
#[cfg(not(any(target_os = "macos", windows)))]
};
let usage = match rollup::Reader::default()
.read(&config.routing_log, chrono::Local::now().date_naive())
{
let _ = settings;
eprintln!("The desktop window supports macOS and Windows.");
ExitCode::FAILURE
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::default().probe(&config.server_url);
for row in summary::build(status, &usage, &config, &rates) {
println!("{row}");
}
ExitCode::SUCCESS
}
97 changes: 97 additions & 0 deletions crates/switchyard-desktop/src/pricing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// 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<Pricing, String> {
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<f64>,
/// This field adds up the calls that have a recorded cost.
pub known: f64,
/// This flag distinguishes a recorded zero cost from missing costs.
pub has_known_cost: bool,
/// 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<f64>,
/// 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 {
/// This function totals recorded costs and prices only answer tokens for the baseline.
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,
has_known_cost: cost.estimated_calls > 0 || cost.provider_reported_calls > 0,
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<Savings> {
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<f64> {
(self.baseline > 0.0).then(|| self.saved() / self.baseline * 100.0)
}
}
17 changes: 17 additions & 0 deletions crates/switchyard-desktop/src/rollup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ impl ModelTokens {
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.
Expand Down
Loading
Loading