Skip to content
Closed
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
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions crates/switchyard-costs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, String> {
Self::parse_toml(include_str!("../prices.toml"))
Expand Down
8 changes: 6 additions & 2 deletions crates/switchyard-desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,27 @@
[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"
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"
Expand All @@ -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"] }
Expand Down
165 changes: 165 additions & 0 deletions crates/switchyard-desktop/src/config.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf, String> {
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<PathBuf>,
}

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<Self, String> {
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<PathBuf, String> {
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::<toml::Value>(&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<PathBuf, String> {
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}");
}
}
149 changes: 149 additions & 0 deletions crates/switchyard-desktop/src/health.rs
Original file line number Diff line number Diff line change
@@ -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<bool> {
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<String, String> {
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<url::Url, String> {
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");
}
}
Loading
Loading