diff --git a/Cargo.lock b/Cargo.lock index 9c501a07a9..8921f71dd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2764,25 +2764,6 @@ dependencies = [ "libc", ] -[[package]] -name = "libxdo" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" -dependencies = [ - "libxdo-sys", -] - -[[package]] -name = "libxdo-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" -dependencies = [ - "libc", - "x11", -] - [[package]] name = "line-clipping" version = "0.3.8" @@ -2978,7 +2959,6 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "libxdo", "objc2", "objc2-app-kit", "objc2-core-foundation", @@ -3317,15 +3297,8 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.1", "block2", - "libc", "objc2", - "objc2-cloud-kit", - "objc2-core-data", "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", "objc2-foundation", "objc2-quartz-core", ] @@ -3347,7 +3320,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -3408,19 +3380,6 @@ dependencies = [ "objc2-core-graphics", ] -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -3444,7 +3403,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", - "libc", "objc2", "objc2-core-foundation", ] @@ -5504,23 +5462,6 @@ dependencies = [ "wiremock", ] -[[package]] -name = "switchyard-menubar" -version = "0.3.0" -dependencies = [ - "chrono", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "serde", - "serde_json", - "switchyard-costs", - "switchyard-protocol", - "tempfile", - "toml 0.9.12+spec-1.1.0", - "tray-icon", -] - [[package]] name = "switchyard-nemo-relay-plugin" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 60228cabdf..bd81b1cd9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ members = [ "crates/switchyard-nemo-relay-plugin", "crates/switchyard-runner", "crates/switchyard-desktop", - "crates/switchyard-menubar", "crates/switchyard-costs", "crates/switchyard-server", "crates/switchyard-skill-distillation", diff --git a/INSTALLATION.md b/INSTALLATION.md index bf07cf47aa..3a158d609e 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -36,22 +36,73 @@ See [Getting Started](docs/getting_started.md#server-path) for a complete TOML deployment and [`switchyard-server`](crates/switchyard-server/README.md) for the configuration reference. -## Desktop installer +## Server Keys + +In a server config, each LLM client and decision client names its API key +variable with `api_key_env`. A server you start from a terminal reads those +variables from your shell, as it always has. The installed server does not: on +macOS launchd starts it, on Linux systemd, and on Windows Task Scheduler. None +of them runs your shell, so the installed server never sees the variables that +`~/.zshrc` or `~/.bashrc` exports. + +The installed server reads each key from the system credential store instead: +macOS Keychain, Windows Credential Manager, or the Linux Secret Service (GNOME +Keyring on Ubuntu and GNOME, KDE Wallet on Plasma). A key is saved under its +variable name, such as `NVIDIA_API_KEY`. Before each start, the installed +helper reads the keys that the config names and passes them to the server. A +saved key replaces any value the service manager passes. If a variable has no +saved key, the server does not start, and the server log names the variable. + +To save, replace, or remove a key in the desktop app, open **Settings → Server +keys**. Type or paste the key, or choose **Import from shell** to read the +variable once from your login shell. From a terminal on macOS or Linux, run the +installed helper: -From a source checkout, run `cargo desktop --help` for the commands and -`cargo desktop install --dry-run` to preview installation. This checkout has -the installer helper but does not yet build the desktop app executable, so -full installation cannot complete. Uninstall keeps settings, history, and -recovery copies. +```bash +~/.switchyard/bin/switchyard-desktop-install key status --config ~/.switchyard/composite.toml +~/.switchyard/bin/switchyard-desktop-install key set NVIDIA_API_KEY # prompts without echo +~/.switchyard/bin/switchyard-desktop-install key import NVIDIA_API_KEY # reads it from your login shell +~/.switchyard/bin/switchyard-desktop-install key remove NVIDIA_API_KEY +``` -The installer requires the repository's Rust toolchain. macOS uses LaunchAgents; -Linux requires a systemd user session. Windows requires MSVC build tools, -WebView2, and a Task Scheduler user session. +On Windows, the installer puts the helper in +`%LOCALAPPDATA%\Programs\Switchyard`. There `key set` reads the key from +standard input without a prompt, and `key import` reads the variable from your +Windows environment variables. From PowerShell: + +```powershell +$helper = "$env:LOCALAPPDATA\Programs\Switchyard\switchyard-desktop-install.exe" +& $helper key status --config "$env:USERPROFILE\.switchyard\composite.toml" +[Net.NetworkCredential]::new('', (Read-Host 'Key' -AsSecureString)).Password | & $helper key set NVIDIA_API_KEY +& $helper key import NVIDIA_API_KEY +& $helper key remove NVIDIA_API_KEY +``` -On Windows, run install and uninstall from the source checkout with Cargo. -The installed helper rejects changes to its own installation because Windows -locks running executables. The app's Update command uses a temporary helper -copy. The installed helper still supports `uninstall --dry-run`. +Restart the server after you change a key. If you rename a variable in +`api_key_env`, save a key under the new name. The old key stays saved until you +remove it in Keychain Access, Credential Manager, or Seahorse. + +`cargo desktop install` never saves, reads, or changes a key, even when your +terminal sets the variable. It checks the config without keys, then lists each +variable that has no saved key. On a first install, the server starts after you +save those keys in **Settings → Server keys** and restart it. In the terminal +app, press `7` to open the Settings page. There `k` lists the variables that the +config names and whether each has a saved key, `s` saves a key, and `i` imports +it from your login shell. Press `b` and then `y` to restart the server. +Uninstalling keeps the saved keys, the config, and history. Switchyard never +writes keys to app settings, service definitions, or logs. Model-list keys that +you enter in Routes are stored separately. + +On macOS, Keychain ties each key to the helper that saved it. Keychain treats a +rebuilt helper as a new program, so after `cargo desktop install` macOS may ask +once per key whether to allow `switchyard-desktop-install`. Choose **Always +Allow**. + +On Linux, the login keyring must be unlocked. A desktop login normally unlocks +it. When the server service starts after login, it waits up to a minute for the +keyring to unlock. Over SSH without a desktop session, or with a window manager +that does not unlock the keyring, the server does not start and its log says +the keyring is locked. ## Linux Codex Service @@ -63,13 +114,16 @@ port while the service is stopped and receive your login and prompts. From a checkout, preview or install the service: ```bash -make install-linux-dry-run -make install-linux +cargo desktop install --dry-run +cargo desktop install systemctl --user status switchyard codex -p sy ``` -The installer builds and installs `~/.switchyard/bin/switchyard-server`. +The installer builds and installs `~/.switchyard/bin/switchyard-server` and +`~/.switchyard/bin/switchyard-desktop`. Run the same command to reinstall from +the current checkout; update the checkout first to install newer code. +Run `~/.switchyard/bin/switchyard-desktop --tui` to open the terminal app. It creates `~/.switchyard/composite.toml` if missing and keeps existing edits. It replaces `~/.config/systemd/user/switchyard.service` and restarts the service. It writes `~/.codex/sy.config.toml`, backing up a changed profile first. @@ -79,20 +133,61 @@ The profile format requires [Codex 0.134.0 or newer](https://developers.openai.c Remove any old `[profiles.sy]` table from `~/.codex/config.toml` before using it. Set `SY_HOME` or `SY_PORT` to change the server directory or port (default 4123). -`SY_HOME` must not contain whitespace, control characters, or a trailing -backslash; `SY_PORT` must contain only digits. `XDG_CONFIG_HOME` and `CODEX_HOME` +`SY_HOME` must be an absolute UTF-8 path without ASCII control characters. +`SY_PORT` must be a number from 1 to 65535. `XDG_CONFIG_HOME` and `CODEX_HOME` set the systemd and Codex config directories. Read server logs with `journalctl --user -u switchyard`. Routing records are stored in `~/.switchyard/routing.jsonl`. Use `systemctl --user edit switchyard` for service changes that survive reinstalling. -Remove the service and profile with `make uninstall-linux`. This also removes +Remove the service and profile with `cargo desktop uninstall`. This also removes marked Codex aliases left by older installs from existing `.bashrc` and `.zshrc` files. If upgrading an older install, uninstall first and run `unalias codex` in any open shell. The server directory, routing records, -profile backups, and systemd drop-in files stay in place. Use the same path -overrides when installing and uninstalling. +profile backups, and systemd drop-in files stay in place. Uninstall uses the +recorded installation. Conflicting environment overrides require +`cargo desktop uninstall --settings FILE` to select it explicitly. + +## Windows Desktop App + +From PowerShell in the repository, run: + +```powershell +cargo desktop install --dry-run +cargo desktop install +``` + +Windows needs the Rust MSVC toolchain, Visual Studio C++ build tools, and +[WebView2](https://v2.tauri.app/start/prerequisites/#windows). +The installer uses Task Scheduler for the current logged-in user. It installs +executables in `%LOCALAPPDATA%\Programs\Switchyard`, falling back to +`%USERPROFILE%\AppData\Local\Programs\Switchyard` when `LOCALAPPDATA` is unset. +It saves that location and adds a Start menu shortcut. Two independent tasks +start the server and desktop app after login with ordinary user privileges. +Quitting the app leaves the server running. + +Run `cargo desktop install` again to build and install the current checkout. +This Cargo command waits for installation to finish. The app's source update +uses the saved checkout and runs from a temporary Rust executable so Windows +can release and replace the installed files; read `SY_HOME\logs\update.log` for +its progress and errors. The installed installer cannot install, update, or +uninstall its own installation, because Windows keeps a running program from +being replaced. Use `cargo desktop` from the source checkout instead. +The installer builds and validates before stopping the tasks. If a binary +replacement fails, it tries to restore the previous binaries. If restoration +also fails, the error names the directory containing recovery files. +Rerun installation if a later task or settings operation fails; the complete +installation is not one transaction. + +Settings, server config, accounts, and routing history stay in +`%USERPROFILE%\.switchyard` unless `SY_HOME` changes the directory. Server and app +logs are in `SY_HOME\logs`. Model-list keys entered in Routes are saved in the +current user's Windows Credential Manager. Coding tools keep their own login files. + +Use `cargo desktop uninstall` to stop and remove both tasks, the shortcut, and +the installed executables. Uninstall preserves settings, accounts, and history, +and makes a recovery copy of the Codex profile before removing it. ## Rust Libraries @@ -123,3 +218,6 @@ uv run pytest tests/ -v The `dev` dependency group contains testing and linting tools and is not exposed in the published wheel metadata. + +See the [desktop and terminal quickstart](crates/switchyard-desktop/README.md) for +first-run setup, reviewed settings changes, usage, recovery, and safe uninstall. diff --git a/Makefile b/Makefile deleted file mode 100644 index 7136e3a263..0000000000 --- a/Makefile +++ /dev/null @@ -1,37 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -.DEFAULT_GOAL := help -.PHONY: help install-linux install-linux-dry-run uninstall-linux install-macos install-macos-dry-run uninstall-macos - -help: - @echo "install-linux Install the systemd user service and Codex profile" - @echo "install-linux-dry-run Preview installation without changes" - @echo "uninstall-linux Remove the service and Codex profile" - @echo "install-macos Install the background server and menu bar app" - @echo "install-macos-dry-run Preview installation without changes" - @echo "uninstall-macos Remove the LaunchAgents and Codex profile" - -## Install the Switchyard background server as a systemd user service. -install-linux: - @scripts/linux/install.sh - -## Print what install-linux would do, without changing anything. -install-linux-dry-run: - @scripts/linux/install.sh --dry-run - -## Remove the systemd user service, the sy Codex profile, and the codex alias. -uninstall-linux: - @scripts/linux/uninstall.sh - -## Install the Switchyard background server and menu bar app for this user. -install-macos: - @scripts/macos/install.sh - -## Print what install-macos would do, without changing anything. -install-macos-dry-run: - @scripts/macos/install.sh --dry-run - -## Remove the LaunchAgents, the sy Codex profile, and the codex alias. -uninstall-macos: - @scripts/macos/uninstall.sh diff --git a/README.md b/README.md index 053e12812b..625a902247 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ LiteLLM and NeMo Relay run in your own deployment. Follow each gateway's guidanc [Try routing locally](docs/getting_started.md#server-path) with the standalone proxy for demos and evaluation, then [verify routing is active](docs/getting_started.md#verify-routing-is-active). +The [desktop and terminal quickstart](crates/switchyard-desktop/README.md) covers +local installation, coding-tool connections, sessions, usage, recovery, and uninstall. + Agent-specific guides are available for [pi](docs/integrations/pi.md) and [Oh My Pi](docs/integrations/oh_my_pi.md). @@ -80,7 +83,7 @@ Features and integrations recently merged into `main`. Select models in Node.js or try automatic routing through Pi’s virtual models. [TypeScript bindings →](bindings/typescript/README.md) · [Pi example →](examples/pi/README.md) - **2026-10-08 · Run Codex with Switchyard on macOS**\ - Install a background router and Codex profile, then track usage and estimated savings from the menu bar. [Setup guide →](crates/switchyard-menubar/README.md) + Install a background router and Codex profile, then track usage and estimated savings in the desktop app. [Setup guide →](crates/switchyard-desktop/README.md) ## Routing algorithms diff --git a/crates/switchyard-desktop/src/gui.rs b/crates/switchyard-desktop/src/gui.rs index b8141870d5..f1e8750803 100644 --- a/crates/switchyard-desktop/src/gui.rs +++ b/crates/switchyard-desktop/src/gui.rs @@ -662,7 +662,7 @@ pub fn run(controller: Controller, settings: PathBuf) -> Result<(), String> { _ => {} }) .build(app)?; - // Like the menu bar app, the tray rereads usage on the settings interval. + // Like the earlier menu bar app, the tray rereads usage on the settings interval. let handle = app.handle().clone(); std::thread::spawn(move || { loop { diff --git a/crates/switchyard-menubar/Cargo.toml b/crates/switchyard-menubar/Cargo.toml deleted file mode 100644 index b839ad9c97..0000000000 --- a/crates/switchyard-menubar/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[package] -name = "switchyard-menubar" -version.workspace = true -description = "macOS menu bar companion for a locally running 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 - -[dependencies] -switchyard-costs.workspace = true -switchyard-protocol.workspace = true -chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } -serde.workspace = true -serde_json.workspace = true -toml = "0.9" - -[target.'cfg(target_os = "macos")'.dependencies] -objc2 = "0.6" -objc2-app-kit = "0.3" -objc2-foundation = "0.3" -tray-icon = "0.25" - -[dev-dependencies] -tempfile = "3" diff --git a/crates/switchyard-menubar/README.md b/crates/switchyard-menubar/README.md deleted file mode 100644 index c58c9d0b37..0000000000 --- a/crates/switchyard-menubar/README.md +++ /dev/null @@ -1,135 +0,0 @@ -# switchyard-menubar - -A macOS menu bar companion for a Switchyard server running in the background. - -The server does the routing. This process only reads what the server already -wrote, so quitting it never affects traffic. - -Click the glyph to see whether the server is answering, today's and this -week's requests and tokens, estimated savings, and which models served the -week. It can also restart the server and open either config file. - -## Install - -From the repository root: - -```sh -make install-macos # or install-macos-dry-run to see the steps first -make uninstall-macos -``` - -That builds `switchyard-server` and `switchyard-menubar` into -`~/.switchyard/bin`, writes a server config and menu bar settings, loads two -LaunchAgents, and writes a `sy` Codex profile. Use the CLI profile with -`codex -p sy`. - -Your server config and menu bar settings are never overwritten once they -exist, so edits survive a reinstall. Logs are in `~/.switchyard/logs/`. - -## Settings - -`~/.switchyard/menubar.toml`. Any key may be omitted. - -```toml -server_url = "http://127.0.0.1:4123" -routing_log = "~/.switchyard/routing.jsonl" -baseline_model = "gpt-5.6-sol" -refresh_seconds = 30 - -# This optional file replaces the bundled price table. -# pricing_file = "~/.switchyard/prices.toml" -``` - -The server and menubar use the same [bundled rates](../switchyard-costs/prices.toml) by -default. To override them, point the server's `--pricing-file` and the menubar's -`pricing_file` at the same TOML or JSON file. The file replaces the entire bundled table. -Bundled rates stay fixed until you install a version with updated rates. The table -includes text-token rates for OpenAI, Claude, Gemini, and DeepSeek models, plus OpenRouter -rates for other popular models. Direct-provider and OpenRouter rates use separate model -IDs, such as `gemini-3.8-flash` and `google/gemini-3.8-flash`. Estimates exclude -long-context and service-tier adjustments, OpenAI's 12-hour cache-write pricing, tool -fees, and time-based cache storage. Each model has one rate at all times of day. The -provider that OpenRouter selects can change the charge. Some Gemini rates are promotions; -the table lists their end dates. Use an override when your provider charges different rates. -Key each entry by the model ID in the routing log. Rates are in USD per million tokens: - -```toml -["gpt-5.6-sol"] -input = 4 -cache_read = 0.4 -cache_write = 5 -output = 20 -``` - -Use a `.toml` extension for TOML. The extension check ignores case; other filenames -are read as JSON. - -These example rates are estimates. Replace them with your provider's rates. -The [pi guide](../../docs/integrations/pi.md#estimate-the-cost) explains -cache-write rates and how to add other models. Rates load at startup; restart both -applications after editing them. An unreadable or invalid override file stops startup; -the applications do not use bundled rates instead. - -## How savings are computed - -Actual spending is the sum of the costs saved in the routing log, including routing -calls. Changing prices does not change saved costs. The baseline is an estimate of -what the answer traffic would have cost using `baseline_model`; it excludes routing -calls. The server and menubar share the code for cost totals and token pricing, including -cache-read and cache-write rates. -The baseline assumes the same token and cache counts as the observed answer traffic. - -The menubar shows estimated savings only when actual costs and the baseline estimate -are complete. A recorded `cost: null` keeps spending unknown. A missing baseline rate -or required one-hour cache-write rate keeps the baseline unknown. Missing or invalid answer -usage also keeps the baseline unknown. Token counts still -appear when savings cannot be calculated. The log can be read while the server is offline. - -The bundled `codex-auto-review` rates match `gpt-6-sol`. Codex sends these approval -reviews even without Switchyard. If your `baseline_model` uses different rates, add -matching `codex-auto-review` rates to your override file to give those reviews the -same estimated cost with and without Switchyard. Matching review costs leave dollar -savings unchanged but reduce the savings percentage. ChatGPT subscription traffic -may have no per-token bill; token-rate estimates are comparisons at the supplied rates. - -## Migrate older settings and logs - -Inline `[prices]` settings and log records without a `cost` field require migration. -Stop the server and menubar before migrating. From the repository root, run: - -```sh -cargo run -p switchyard-costs -- \ - ~/.switchyard/menubar.toml ~/.switchyard/routing.jsonl ~/.switchyard/migrated-costs -``` - -The destination directory must not exist. The command writes `menubar.toml`, -`prices.json`, and `routing.jsonl` there and leaves the originals untouched. It preserves -existing costs, including null, and adds estimates only to records without a cost field. -Missing usage or rates leave the reconstructed cost unknown. Malformed or unfinished -records stop migration; failed migration outputs are removed. - -The command uses the rates in the old settings. It cannot recover historical provider -charges or historical prices. It maps `input_per_mtok` to `input` and `cache_write`, -`output_per_mtok` to `output`, and `cached_input_per_mtok` to `cache_read` (or the input -rate when omitted). This preserves the old cache-write assumption. Review those rates -for your providers; add `cache_write_1h` when needed. Reconstruction is always marked -`estimated`, even when it uses historical prices. - -Review the generated files, then start the menubar with the generated settings path. -Those settings point at the generated log and pricing file. Update the server's -LaunchAgent to use those same paths for `--routing-log-file` and `--pricing-file` before -restarting it. Keep the originals as backups. Reinstalling preserves existing files; -it does not migrate them. - -## Codex profiles - -`codex --profile sy` reads `~/.codex/sy.config.toml`. The installer writes this -standalone profile and keeps your existing `~/.codex/config.toml` unchanged. -Reinstalling backs up the profile before replacing it if its contents change. - -The profile sets `model`, `model_provider`, and the Switchyard provider details. -It leaves `approval_policy` and `sandbox_mode` to your existing Codex settings. - -Codex.app cannot use the profile. The installer leaves its config unchanged. -For older installs that replaced `config.toml`, `make uninstall-macos` restores -`config.toml.direct` and keeps a copy of the active config before replacing it. diff --git a/crates/switchyard-menubar/src/app.rs b/crates/switchyard-menubar/src/app.rs deleted file mode 100644 index 3695ce9b0f..0000000000 --- a/crates/switchyard-menubar/src/app.rs +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Ties the pieces together: read the log, probe the server, build the rows. - -use chrono::Local; - -use crate::config::Config; -use crate::health::probe; -use crate::rollup; -use crate::summary::{Row, build}; - -/// Reads new log entries, probes the server, and builds the menu body. -pub fn refresh(config: &Config, log: &mut rollup::Reader) -> Vec { - let usage = match log.read(&config.routing_log, Local::now().date_naive()) { - Ok(usage) => usage, - Err(error) => return vec![Row::Label(format!("Cannot read routing log: {error}"))], - }; - build(probe(&config.server_url), &usage, config) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reads_the_configured_log() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("routing.jsonl"); - let ts = Local::now().to_rfc3339(); - std::fs::write( - &path, - format!( - r#"{{"ts":"{ts}","model":"luna","tier":"","prompt_tokens":1000,"cached_tokens":0,"completion_tokens":100,"reasoning_tokens":0,"cost":null}}"# - ) + "\n", - ) - .expect("write log"); - - let rows = refresh( - &Config { - routing_log: path, - ..Config::default() - }, - &mut rollup::Reader::default(), - ); - - assert!( - rows.contains(&Row::Label("Today — 1 request · 1100 tokens".to_string())), - "unexpected rows: {rows:?}" - ); - } -} diff --git a/crates/switchyard-menubar/src/config.rs b/crates/switchyard-menubar/src/config.rs deleted file mode 100644 index 185fca0719..0000000000 --- a/crates/switchyard-menubar/src/config.rs +++ /dev/null @@ -1,176 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Menu bar settings, read from `~/.switchyard/menubar.toml`. - -use std::path::{Path, PathBuf}; - -use serde::Deserialize; - -use switchyard_costs::Pricing; - -/// Everything the menu bar needs to find the server and price its traffic. -/// Missing keys fall back to [`Config::default`]. -#[derive(Clone, Debug, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct Config { - /// Base URL the server listens on, used for the health check. - pub server_url: String, - /// JSONL file the server appends routing records to. - pub routing_log: PathBuf, - /// Server TOML, opened by the "Open server config" menu item. - pub config_file: PathBuf, - /// LaunchAgent label used to restart the server. - pub launchd_label: String, - /// How often the menu contents are recomputed. - pub refresh_seconds: u64, - /// Model the traffic is assumed to have used without Switchyard. - pub baseline_model: String, - /// An optional TOML or JSON file replaces bundled rates for the baseline estimate. - pub pricing_file: Option, - /// Rates load once when the settings are read. - #[serde(skip)] - pub prices: Pricing, -} - -impl Default for Config { - fn default() -> Self { - Self { - server_url: "http://127.0.0.1:4123".to_string(), - routing_log: PathBuf::from("~/.switchyard/routing.jsonl"), - config_file: PathBuf::from("~/.switchyard/composite.toml"), - launchd_label: "com.nvidia.switchyard.server".to_string(), - refresh_seconds: 30, - baseline_model: "gpt-5.6-sol".to_string(), - pricing_file: None, - prices: Pricing::default(), - } - } -} - -impl Config { - /// Parses settings, falling back to defaults when the file is absent. - pub fn load(path: &Path) -> Result { - let mut config: Self = match std::fs::read_to_string(path) { - Ok(text) => toml::from_str(&text) - .map_err(|error| format!("parse {}: {error}", path.display()))?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Self::default(), - Err(error) => return Err(format!("read {}: {error}", path.display())), - }; - if let Some(path) = config.pricing_file.as_ref() { - let path = expand_home(path); - config.prices = Pricing::load(&path)?; - config.pricing_file = Some(path); - } else { - config.prices = Pricing::bundled()?; - } - config.routing_log = expand_home(&config.routing_log); - config.config_file = expand_home(&config.config_file); - Ok(config) - } - - /// Default settings path, `~/.switchyard/menubar.toml`. - pub fn default_path() -> PathBuf { - expand_home(Path::new("~/.switchyard/menubar.toml")) - } -} - -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(()) -} - -/// Rewrites a leading `~`. Paths are written by hand in the settings file, -/// where `~` is the natural way to spell a home path. -pub fn expand_home(path: &Path) -> PathBuf { - let home = std::env::var_os("HOME").map_or_else(|| PathBuf::from("."), PathBuf::from); - expand_under(path, &home) -} - -fn expand_under(path: &Path, home: &Path) -> PathBuf { - match path.strip_prefix("~") { - Ok(rest) => home.join(rest), - Err(_) => path.to_path_buf(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reads_settings_and_prices() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("menubar.toml"); - std::fs::write( - &path, - r#" -server_url = "http://127.0.0.1:9000" -baseline_model = "sol" - -"#, - ) - .expect("write settings"); - - let config = Config::load(&path).expect("load"); - - assert_eq!(config.server_url, "http://127.0.0.1:9000"); - assert_eq!(config.baseline_model, "sol"); - assert!(config.pricing_file.is_none()); - assert_eq!(config.refresh_seconds, 30, "unset keys keep their default"); - } - - #[test] - fn a_missing_file_yields_defaults() { - let dir = tempfile::tempdir().expect("tempdir"); - - let config = Config::load(&dir.path().join("absent.toml")).expect("load"); - - assert_eq!(config.server_url, "http://127.0.0.1:4123"); - assert!(config.pricing_file.is_none()); - let empty = dir.path().join("empty.toml"); - std::fs::write(&empty, "").expect("write settings"); - let loaded = Config::load(&empty).expect("load empty settings"); - assert_eq!(config.routing_log, loaded.routing_log); - assert_eq!(config.config_file, loaded.config_file); - } - - #[test] - fn rejects_unparsable_settings() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("menubar.toml"); - std::fs::write(&path, "server_url = ").expect("write settings"); - - assert!(Config::load(&path).is_err()); - } - - #[test] - fn validates_arbitrary_toml_files() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("codex.toml"); - std::fs::write(&path, "[model_providers.\"sy\"]\nname = \"Switchyard\"\n") - .expect("write config"); - - assert!(validate_toml_file(&path).is_ok()); - - std::fs::write(&path, "[model_providers.\"sy\"]\nname = ").expect("write config"); - assert!(validate_toml_file(&path).is_err()); - } - - #[test] - fn expands_a_leading_tilde() { - let home = Path::new("/Users/example"); - - assert_eq!( - expand_under(Path::new("~/.switchyard/routing.jsonl"), home), - PathBuf::from("/Users/example/.switchyard/routing.jsonl") - ); - assert_eq!( - expand_under(Path::new("/tmp/routing.jsonl"), home), - PathBuf::from("/tmp/routing.jsonl") - ); - } -} diff --git a/crates/switchyard-menubar/src/health.rs b/crates/switchyard-menubar/src/health.rs deleted file mode 100644 index 9105ca75a8..0000000000 --- a/crates/switchyard-menubar/src/health.rs +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Liveness probe against the local server's `/health` endpoint. -//! -//! The request is written by hand over TCP. The server is always on loopback -//! over plain HTTP, so a full HTTP client would buy nothing here. - -use std::io::{BufRead, BufReader, Write}; -use std::net::{TcpStream, ToSocketAddrs}; -use std::time::Duration; - -/// How long the probe waits before calling the server unreachable. Short, -/// because it runs on the thread that draws the menu. -const TIMEOUT: Duration = Duration::from_millis(500); - -/// Whether the server answered its health check. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ServerStatus { - Running, - Stopped, -} - -/// 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); - let address = authority - .to_socket_addrs()? - .next() - .ok_or_else(|| std::io::Error::other(format!("no address for {authority}")))?; - let mut stream = TcpStream::connect_timeout(&address, TIMEOUT)?; - stream.set_read_timeout(Some(TIMEOUT))?; - stream.set_write_timeout(Some(TIMEOUT))?; - stream.write_all(b"GET /health HTTP/1.0\r\n\r\n")?; - let mut status_line = String::new(); - BufReader::new(stream).read_line(&mut status_line)?; - Ok(status_line.contains(" 200")) -} - -fn authority(server_url: &str) -> String { - let rest = server_url.rsplit("://").next().unwrap_or(server_url); - let mut authority = rest.split('/').next().unwrap_or(rest).to_string(); - let host_end = authority.rfind(']').map_or(0, |index| index + 1); - if !authority[host_end..].contains(':') { - authority.push_str(":4123"); - } - - authority -} - -#[cfg(test)] -mod tests { - use super::*; - use std::net::TcpListener; - - /// Serves one canned response, then closes. - fn serve(response: &'static str) -> String { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); - let port = listener.local_addr().expect("addr").port(); - std::thread::spawn(move || { - if let Ok((mut stream, _)) = listener.accept() { - let _ = stream.write_all(response.as_bytes()); - } - }); - format!("http://127.0.0.1:{port}") - } - - #[test] - fn reports_running_only_on_a_200() { - let ok = serve("HTTP/1.1 200 OK\r\n\r\n"); - let down = serve("HTTP/1.1 503 Service Unavailable\r\n\r\n"); - - assert_eq!(probe(&ok), ServerStatus::Running); - assert_eq!(probe(&down), ServerStatus::Stopped); - } - - #[test] - fn reports_stopped_when_nothing_is_listening() { - // Binding then dropping yields a port with no listener. - let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); - let port = listener.local_addr().expect("addr").port(); - drop(listener); - - assert_eq!( - probe(&format!("http://127.0.0.1:{port}")), - ServerStatus::Stopped - ); - } - - #[test] - fn defaults_the_port_after_an_ipv6_literal() { - assert_eq!(authority("http://[::1]/v1"), "[::1]:4123"); - assert_eq!(authority("http://[::1]:9000/v1"), "[::1]:9000"); - assert_eq!(authority("http://localhost"), "localhost:4123"); - } - - #[test] - fn accepts_a_url_with_a_path_or_no_port() { - let ok = serve("HTTP/1.1 200 OK\r\n\r\n"); - - assert_eq!(probe(&format!("{ok}/v1")), ServerStatus::Running); - assert_eq!(probe("http://localhost"), ServerStatus::Stopped); - } -} diff --git a/crates/switchyard-menubar/src/icon.rs b/crates/switchyard-menubar/src/icon.rs deleted file mode 100644 index 5139e4ad01..0000000000 --- a/crates/switchyard-menubar/src/icon.rs +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! The menu bar glyph: a track that splits in two, drawn in code. -//! -//! macOS scales the image to 18 points tall, so it is drawn at 2x for retina -//! displays. It is a template image, so only the alpha channel matters and the -//! system recolors it for light and dark menu bars. - -/// Side length of the generated image, in pixels. -pub const SIZE: u32 = 36; - -/// Half the stroke width, in pixels. -const HALF_STROKE: f64 = 1.7; - -const SEGMENTS: [((f64, f64), (f64, f64)); 3] = [ - ((5.0, 18.0), (17.0, 18.0)), - ((17.0, 18.0), (30.0, 7.0)), - ((17.0, 18.0), (30.0, 29.0)), -]; - -/// Renders the glyph as RGBA pixels, row-major from the top left. -pub fn glyph() -> Vec { - let mut pixels = Vec::with_capacity((SIZE * SIZE * 4) as usize); - for y in 0..SIZE { - for x in 0..SIZE { - let point = (f64::from(x) + 0.5, f64::from(y) + 0.5); - let distance = SEGMENTS - .iter() - .map(|(start, end)| distance_to_segment(point, *start, *end)) - .fold(f64::INFINITY, f64::min); - // One pixel of falloff past the stroke edge softens the diagonals. - let coverage = (HALF_STROKE + 0.5 - distance).clamp(0.0, 1.0); - pixels.extend_from_slice(&[0, 0, 0, (coverage * 255.0).round() as u8]); - } - } - pixels -} - -fn distance_to_segment(point: (f64, f64), start: (f64, f64), end: (f64, f64)) -> f64 { - let (dx, dy) = (end.0 - start.0, end.1 - start.1); - let length_squared = dx * dx + dy * dy; - let along = ((point.0 - start.0) * dx + (point.1 - start.1) * dy) / length_squared; - let along = along.clamp(0.0, 1.0); - let (cx, cy) = (start.0 + dx * along, start.1 + dy * along); - ((point.0 - cx).powi(2) + (point.1 - cy).powi(2)).sqrt() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn alpha(pixels: &[u8], x: u32, y: u32) -> u8 { - pixels[((y * SIZE + x) * 4 + 3) as usize] - } - - #[test] - fn draws_a_trunk_that_splits_in_two() { - let pixels = glyph(); - - assert_eq!(pixels.len(), (SIZE * SIZE * 4) as usize); - assert_eq!(alpha(&pixels, 10, 18), 255, "the trunk is solid"); - assert!(alpha(&pixels, 29, 7) > 0, "upper branch reaches its end"); - assert!(alpha(&pixels, 29, 29) > 0, "lower branch reaches its end"); - assert_eq!(alpha(&pixels, 29, 18), 0, "nothing runs straight through"); - assert_eq!(alpha(&pixels, 0, 0), 0, "corners stay transparent"); - } -} diff --git a/crates/switchyard-menubar/src/main.rs b/crates/switchyard-menubar/src/main.rs deleted file mode 100644 index fd42bc6160..0000000000 --- a/crates/switchyard-menubar/src/main.rs +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Menu bar companion for a locally running Switchyard server. -//! -//! The server owns routing; this process only reads what the server already -//! wrote. It shows today's and this week's traffic, and what that traffic -//! would have cost had every call gone to the capable model instead. - -mod app; -mod config; -mod health; -mod pricing; -mod rollup; -mod summary; -// The status item and the glyph it draws are the only platform-specific code. -#[cfg(target_os = "macos")] -mod icon; -#[cfg(target_os = "macos")] -mod tray; - -use std::path::PathBuf; -use std::process::ExitCode; - -use config::Config; - -const USAGE: &str = "\ -Usage: switchyard-menubar [--print | --validate-toml] [FILE] - -Shows Switchyard usage and estimated savings in the macOS menu bar. - - FILE Settings file [default: ~/.switchyard/menubar.toml] - --print Print the current summary and exit, instead of running - --validate-toml Parse FILE as TOML and exit"; - -fn main() -> ExitCode { - let mut settings = None; - let mut print_only = false; - 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" => print_only = true, - "--validate-toml" => validate_toml = true, - _ if arg.starts_with('-') => { - eprintln!("switchyard-menubar: unknown option {arg}\n\n{USAGE}"); - return ExitCode::FAILURE; - } - _ => settings = Some(PathBuf::from(arg)), - } - } - - if validate_toml { - let Some(path) = settings else { - eprintln!("switchyard-menubar: --validate-toml requires a file path"); - return ExitCode::FAILURE; - }; - if let Err(error) = config::validate_toml_file(&path) { - eprintln!("switchyard-menubar: {error}"); - return ExitCode::FAILURE; - } - return ExitCode::SUCCESS; - } - - let settings_path = settings.unwrap_or_else(Config::default_path); - let config = match Config::load(&settings_path) { - Ok(config) => config, - Err(error) => { - eprintln!("switchyard-menubar: {error}"); - return ExitCode::FAILURE; - } - }; - // Off macOS there is no menu bar to attach to, so printing is the whole - // program. That keeps the rollup and pricing logic buildable everywhere. - if print_only || !cfg!(target_os = "macos") { - for row in app::refresh(&config, &mut rollup::Reader::default()) { - match row { - summary::Row::Separator => println!(), - summary::Row::Label(text) => println!("{text}"), - } - } - return ExitCode::SUCCESS; - } - - #[cfg(target_os = "macos")] - if let Err(error) = tray::run(config, settings_path) { - eprintln!("switchyard-menubar: {error}"); - return ExitCode::FAILURE; - } - ExitCode::SUCCESS -} diff --git a/crates/switchyard-menubar/src/pricing.rs b/crates/switchyard-menubar/src/pricing.rs deleted file mode 100644 index e2cf1c98d2..0000000000 --- a/crates/switchyard-menubar/src/pricing.rs +++ /dev/null @@ -1,169 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Counterfactual cost: what routed traffic cost against what it would have -//! cost had every call gone to one capable model. - -use switchyard_costs::{CostTotals, Pricing}; -use switchyard_protocol::{InputCacheUsage, Usage}; - -use crate::rollup::Totals; - -/// What the traffic cost, and what it would have cost without routing. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Savings { - /// Cost of the calls that actually ran, classifier overhead included. - pub actual: f64, - /// Cost of the same caller-facing calls had they all used the baseline model. - pub baseline: f64, -} - -impl Savings { - /// Dollars kept. Negative when routing overhead outweighed the cheaper tier. - pub fn saved(&self) -> f64 { - self.baseline - self.actual - } - - /// Fraction of the baseline bill avoided, or `None` with nothing to compare. - pub fn percent(&self) -> Option { - (self.baseline > 0.0).then(|| self.saved() / self.baseline * 100.0) - } -} - -/// Prices a period against the baseline model. -/// -/// Returns `None` when recorded spending or the baseline estimate is incomplete. -pub fn estimate(totals: &Totals, prices: &Pricing, baseline_model: &str) -> Option { - if totals.baseline_unknown { - return None; - } - let mut baseline = CostTotals::default(); - for tokens in totals.routed.values() { - let usage = Usage { - input_tokens: Some(tokens.input), - output_tokens: Some(tokens.output), - cache: Some(Box::new(InputCacheUsage { - cached_input_tokens: Some(tokens.cached_input), - cache_creation_input_tokens: Some(tokens.cache_write), - cache_creation_1h_input_tokens: Some(tokens.cache_write_1h), - })), - ..Usage::default() - }; - baseline.add(prices.estimate(baseline_model, &usage)); - } - Some(Savings { - actual: totals.cost.total_usd?, - baseline: baseline.total_usd?, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::rollup::ModelTokens; - - fn table() -> Pricing { - Pricing::parse( - br#"{ - "sol":{"input":1.25,"cache_read":1.25,"cache_write":1.25,"output":10}, - "luna":{"input":0.25,"cache_read":0.25,"cache_write":0.25,"output":2}, - "terra":{"input":0.05,"cache_read":0.05,"cache_write":0.05,"output":0.4} - }"#, - ) - .expect("rates") - } - - fn record_cost(totals: &mut Totals, usd: f64) { - totals.cost.add(Some(switchyard_costs::RecordedCost { - usd, - source: switchyard_costs::CostSource::Estimated, - })); - } - - fn tokens(input: u64, output: u64) -> ModelTokens { - ModelTokens { - requests: 1, - input, - cached_input: 0, - output, - ..ModelTokens::default() - } - } - - #[test] - fn prices_the_cheaper_tier_against_the_capable_baseline() { - let mut totals = Totals::default(); - totals - .routed - .insert("luna".to_string(), tokens(1_000_000, 100_000)); - - record_cost(&mut totals, 0.45); - let savings = estimate(&totals, &table(), "sol").expect("priced"); - - assert!((savings.actual - 0.45).abs() < 1e-9); - assert!((savings.baseline - 2.25).abs() < 1e-9); - assert!((savings.percent().expect("percent") - 80.0).abs() < 1e-9); - } - - #[test] - fn classifier_overhead_counts_against_savings() { - let mut totals = Totals::default(); - totals - .routed - .insert("luna".to_string(), tokens(1_000_000, 100_000)); - record_cost(&mut totals, 0.45); - let before = estimate(&totals, &table(), "sol").expect("priced"); - - totals - .classifier - .insert("terra".to_string(), tokens(1_000_000, 10_000)); - record_cost(&mut totals, 0.054); - let after = estimate(&totals, &table(), "sol").expect("priced"); - - assert_eq!(after.baseline, before.baseline); - assert!(after.saved() < before.saved()); - } - - #[test] - fn cache_reads_use_the_cached_rate() { - let prices = - Pricing::parse(br#"{"sol":{"input":1,"cache_read":0.1,"cache_write":1,"output":0}}"#) - .expect("rates"); - let mut totals = Totals::default(); - totals.routed.insert( - "sol".to_string(), - ModelTokens { - requests: 1, - input: 1_000_000, - cached_input: 1_000_000, - output: 0, - ..ModelTokens::default() - }, - ); - - record_cost(&mut totals, 1.1); - let savings = estimate(&totals, &prices, "sol").expect("priced"); - - assert!((savings.actual - 1.1).abs() < 1e-9); - } - - #[test] - fn an_unknown_cost_suppresses_savings() { - let mut totals = Totals::default(); - totals - .routed - .insert("unknown".to_string(), tokens(1_000, 100)); - - totals.cost.add(None); - assert!(estimate(&totals, &table(), "sol").is_none()); - assert!(estimate(&totals, &table(), "absent").is_none()); - } - - #[test] - fn an_empty_period_has_no_percentage() { - let savings = estimate(&Totals::default(), &table(), "sol").expect("priced"); - - assert_eq!(savings.saved(), 0.0); - assert!(savings.percent().is_none()); - } -} diff --git a/crates/switchyard-menubar/src/rollup.rs b/crates/switchyard-menubar/src/rollup.rs deleted file mode 100644 index 0641ccb739..0000000000 --- a/crates/switchyard-menubar/src/rollup.rs +++ /dev/null @@ -1,494 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Token totals for today and the past week, read from the server's routing log. - -use std::collections::BTreeMap; -use std::fs::{File, Metadata}; -use std::io::{BufRead, BufReader, Seek, SeekFrom}; -use std::path::Path; - -use chrono::{DateTime, Local, NaiveDate}; -use serde::Deserialize; -use switchyard_costs::{CostTotals, RecordedCost}; - -/// Tier value the server writes for classifier and judge calls. -const CLASSIFIER_TIER: &str = "classifier"; - -/// Billable token counts for one model. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct ModelTokens { - pub requests: u64, - /// Input tokens exclude cache reads and writes. - pub input: u64, - /// Input tokens served from the provider cache. - pub cached_input: u64, - /// Generated tokens, including reasoning. - pub output: u64, - /// Cache-write tokens are separate from uncached input. - pub cache_write: u64, - /// One-hour writes are a subset of all cache writes. - pub cache_write_1h: u64, -} - -impl ModelTokens { - /// Every token the provider counted, cached reads included. - pub fn total(&self) -> u64 { - self.input + self.cached_input + self.cache_write + self.output - } - - fn add(&mut self, other: &Self) { - self.requests += other.requests; - self.input += other.input; - self.cached_input += other.cached_input; - self.output += other.output; - self.cache_write += other.cache_write; - self.cache_write_1h += other.cache_write_1h; - } -} - -/// Tokens for a period, split by who asked for them. -/// -/// 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)] -pub struct Totals { - /// Actual spending comes only from the saved cost of each call. - pub cost: CostTotals, - /// Missing or invalid answer usage keeps savings unknown after totals are merged. - pub baseline_unknown: bool, - /// Calls that served the caller, keyed by the model that answered. - pub routed: BTreeMap, - /// Switchyard's own classifier and judge calls, keyed by model. - pub classifier: BTreeMap, -} - -impl Totals { - fn add(&mut self, record: &Record) { - self.cost.add(record.cost); - if record.tier != CLASSIFIER_TIER { - self.baseline_unknown |= !record.has_baseline_usage(); - } - let bucket = if record.tier == CLASSIFIER_TIER { - &mut self.classifier - } else { - &mut self.routed - }; - bucket - .entry(record.model.clone()) - .or_default() - .add(&record.tokens()); - } - - fn merge(&mut self, other: &Self) { - self.cost.merge(&other.cost); - self.baseline_unknown |= other.baseline_unknown; - for (model, tokens) in &other.routed { - self.routed.entry(model.clone()).or_default().add(tokens); - } - for (model, tokens) in &other.classifier { - self.classifier - .entry(model.clone()) - .or_default() - .add(tokens); - } - } - - /// Calls made on the caller's behalf. - pub fn requests(&self) -> u64 { - self.routed.values().map(|tokens| tokens.requests).sum() - } - - /// Every token spent, Switchyard's own routing overhead included. - pub fn tokens(&self) -> u64 { - self.routed - .values() - .chain(self.classifier.values()) - .map(ModelTokens::total) - .sum() - } - - /// True when nothing was recorded. - pub fn is_empty(&self) -> bool { - self.routed.is_empty() && self.classifier.is_empty() - } -} - -/// What the menu shows. -#[derive(Clone, Debug, Default)] -pub struct Usage { - pub today: Totals, - pub week: Totals, -} - -/// Fields the rollup needs from one routing log line. -#[derive(Deserialize)] -struct Record { - ts: String, - model: String, - #[serde(default)] - tier: String, - #[serde(default)] - prompt_tokens: Option, - #[serde(default)] - cached_tokens: Option, - #[serde(default)] - completion_tokens: Option, - #[serde(default)] - cache_creation_tokens: Option, - #[serde(default)] - cache_creation_1h_tokens: Option, - // A missing field requires migration; explicit null is a valid unknown cost. - #[serde(deserialize_with = "Option::deserialize")] - cost: Option, -} - -impl Record { - // Each record must be valid before aggregation can conceal inconsistent cache counts. - fn has_baseline_usage(&self) -> bool { - let Some(input) = self.prompt_tokens else { - return false; - }; - self.completion_tokens.is_some() - && input - .checked_sub(self.cached_tokens.unwrap_or(0)) - .and_then(|input| input.checked_sub(self.cache_creation_tokens.unwrap_or(0))) - .is_some() - && self.cache_creation_1h_tokens.unwrap_or(0) <= self.cache_creation_tokens.unwrap_or(0) - } - - fn tokens(&self) -> ModelTokens { - ModelTokens { - requests: 1, - // `prompt_tokens` is the whole input, cache reads included. - input: self - .prompt_tokens - .unwrap_or(0) - .saturating_sub(self.cached_tokens.unwrap_or(0)) - .saturating_sub(self.cache_creation_tokens.unwrap_or(0)), - cached_input: self.cached_tokens.unwrap_or(0), - // Completion tokens already include reasoning tokens. - output: self.completion_tokens.unwrap_or(0), - cache_write: self.cache_creation_tokens.unwrap_or(0), - cache_write_1h: self.cache_creation_1h_tokens.unwrap_or(0), - } - } -} - -/// Keeps daily totals and reads only newly completed log lines. -#[derive(Default)] -pub struct Reader { - metadata: Option, - offset: u64, - days: BTreeMap, -} - -impl Reader { - /// A missing log has no usage. Replacement or truncation starts fresh. - 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 metadata = file.metadata()?; - if metadata.len() < self.offset - || !self - .metadata - .as_ref() - .is_some_and(|previous| is_same_file(previous, &metadata)) - { - self.offset = 0; - self.days.clear(); - } - self.metadata = Some(metadata); - 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)?; - // Leave an unfinished line at the offset so the next refresh retries it. - if line.last() != Some(&b'\n') { - break; - } - let record = match serde_json::from_slice::(&line) { - Ok(record) => record, - Err(error) if error.is_data() => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!( - "invalid routing record: {error}; migrate older logs before reading them" - ), - )); - } - Err(_) => { - self.offset += bytes as u64; - continue; - } - }; - self.offset += bytes as u64; - let Ok(ts) = DateTime::parse_from_rfc3339(&record.ts) else { - continue; - }; - let day = ts.with_timezone(&Local).date_naive(); - if day >= week_start { - self.days.entry(day).or_default().add(&record); - } - } - let mut usage = Usage::default(); - for (day, totals) in &self.days { - if *day == today { - usage.today = totals.clone(); - } - if *day <= today { - usage.week.merge(totals); - } - } - Ok(usage) - } -} - -fn is_same_file(left: &Metadata, right: &Metadata) -> bool { - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - left.dev() == right.dev() && left.ino() == right.ino() - } - #[cfg(not(unix))] - { - left.created() - .ok() - .is_some_and(|created| right.created().ok() == Some(created)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn read(path: &Path, today: NaiveDate) -> std::io::Result { - Reader::default().read(path, today) - } - - fn date(text: &str) -> NaiveDate { - text.parse().expect("valid date") - } - - /// 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,"cost":null}}"# - ) - } - - 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 splits_routed_and_classifier_tokens() { - let (_dir, path) = log(&format!( - "{}\n{}\n", - line("2026-09-28T10:00:00.000", "luna", "", 1_000, 200), - line("2026-09-28T10:00:01.000", "terra", "classifier", 300, 10), - )); - - let usage = read(&path, date("2026-09-28")).expect("read"); - - assert_eq!( - usage.today.requests(), - 1, - "classifier calls are not requests" - ); - assert_eq!(usage.today.routed["luna"].input, 1_000); - assert_eq!(usage.today.classifier["terra"].input, 300); - assert_eq!(usage.today.tokens(), 1_000 + 200 + 300 + 10); - } - - #[test] - fn separates_cache_reads_from_full_price_input() { - let record: Record = 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,"cost":null}"#, - ) - .expect("parse record"); - let tokens = record.tokens(); - assert_eq!(tokens.input, 400); - assert_eq!(tokens.cached_input, 600); - assert_eq!(tokens.output, 50); - assert_eq!(tokens.total(), 1_050); - } - - #[test] - fn counts_completion_tokens_once_and_prices_them() { - let record: Record = serde_json::from_str( - r#"{"ts":"2026-09-28T10:00:00.000Z","model":"m","prompt_tokens":12,"cached_tokens":0,"cache_creation_tokens":10,"completion_tokens":5,"reasoning_tokens":3,"cost":{"usd":0.000022,"source":"provider_reported"}}"#, - ) - .expect("parse record"); - - let tokens = record.tokens(); - - assert_eq!(tokens.input, 2); - assert_eq!(tokens.output, 5); - assert_eq!(tokens.total(), 17); - let mut totals = Totals::default(); - totals.add(&record); - let prices = switchyard_costs::Pricing::parse( - br#"{"m":{"input":1,"cache_read":1,"cache_write":1,"cache_write_1h":3,"output":2}}"#, - ) - .expect("rates"); - let savings = crate::pricing::estimate(&totals, &prices, "m").expect("priced"); - assert!((savings.actual - 0.000022).abs() < 1e-12); - // A known zero charge still needs valid usage for the hypothetical baseline. - for fields in [ - r#""prompt_tokens":null,"completion_tokens":5"#, - r#""completion_tokens":5"#, - r#""prompt_tokens":2,"cached_tokens":3,"completion_tokens":5"#, - r#""prompt_tokens":20,"cache_creation_tokens":2,"cache_creation_1h_tokens":3,"completion_tokens":5"#, - ] { - let record: Record = serde_json::from_str(&format!( - r#"{{"ts":"2026-09-28T10:00:00Z","model":"m",{fields},"cost":{{"usd":0,"source":"provider_reported"}}}}"# - )).expect("record with incomplete baseline usage"); - let mut incomplete = Totals::default(); - incomplete.add(&record); - incomplete.merge(&totals); - assert_eq!(incomplete.cost.total_usd, Some(0.000022)); - assert!(crate::pricing::estimate(&incomplete, &prices, "m").is_none()); - } - } - - #[test] - fn the_week_covers_seven_days() { - let mut contents = String::new(); - for day in 18..=28 { - contents.push_str(&line( - &format!("2026-09-{day:02}T10:00:00.000"), - "luna", - "", - 100, - 10, - )); - contents.push('\n'); - } - let (_dir, path) = log(&contents); - - let usage = read(&path, date("2026-09-28")).expect("read"); - - assert_eq!(usage.today.requests(), 1); - assert_eq!(usage.week.requests(), 7); - } - - #[test] - fn skips_lines_it_cannot_parse() { - // The trailing line has no newline yet, as when the server is mid-write. - let (_dir, path) = log(&format!( - "not json\n{}\n{{\"ts\":\"2026-09-2", - line("2026-09-28T10:00:00.000", "luna", "", 100, 10), - )); - - let usage = read(&path, date("2026-09-28")).expect("read"); - - assert_eq!(usage.today.requests(), 1); - } - - #[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); - } - - #[test] - fn drops_old_days_without_rereading_the_log() { - let (_dir, path) = log(&format!( - "{}\n{}\n", - line("2026-09-22T10:00:00.000", "luna", "", 100, 10), - line("2026-09-28T10:00:00.000", "luna", "", 100, 10), - )); - let mut reader = Reader::default(); - assert_eq!( - reader - .read(&path, date("2026-09-28")) - .expect("read") - .week - .requests(), - 2 - ); - let usage = reader.read(&path, date("2026-09-29")).expect("next day"); - assert_eq!(usage.today.requests(), 0); - assert_eq!(usage.week.requests(), 1); - } - - #[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); - - std::fs::rename(&path, path.with_extension("old")).expect("rotate"); - std::fs::write(&path, replacement.repeat(3)).expect("replace"); - let usage = reader.read(&path, today).expect("read replacement"); - assert_eq!(usage.today.requests(), 3); - assert_eq!(usage.today.tokens(), 660); - } - - #[test] - fn a_missing_log_is_not_an_error() { - let dir = tempfile::tempdir().expect("tempdir"); - - let usage = read(&dir.path().join("absent.jsonl"), date("2026-09-28")).expect("read"); - - assert!(usage.today.is_empty()); - } -} diff --git a/crates/switchyard-menubar/src/summary.rs b/crates/switchyard-menubar/src/summary.rs deleted file mode 100644 index 42df978304..0000000000 --- a/crates/switchyard-menubar/src/summary.rs +++ /dev/null @@ -1,284 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Turns totals and prices into the rows the menu shows. - -use crate::config::Config; -use crate::health::ServerStatus; -use crate::pricing::{Savings, estimate}; -use crate::rollup::{Totals, Usage}; - -/// Most model rows shown, so the menu stays short. -const MAX_MODEL_ROWS: usize = 5; - -/// One line of the menu. Rows are informational; the actions are fixed. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Row { - Separator, - Label(String), -} - -fn label(text: impl Into) -> Row { - Row::Label(text.into()) -} - -/// Builds the menu body for the current day and trailing week. -pub fn build(status: ServerStatus, usage: &Usage, config: &Config) -> Vec { - let state = match status { - ServerStatus::Running => "running", - ServerStatus::Stopped => "not responding", - }; - let mut rows = vec![ - label(format!("Server: {state} · {}", config.server_url)), - Row::Separator, - ]; - - if usage.week.is_empty() { - rows.push(label("No requests recorded yet")); - return rows; - } - - rows.extend(period("Today", &usage.today, config)); - rows.push(Row::Separator); - rows.extend(period("This week", &usage.week, config)); - - let models = model_shares(&usage.week); - if !models.is_empty() { - rows.push(Row::Separator); - rows.push(label("This week by model")); - rows.extend(models); - } - - if estimate(&usage.week, &config.prices, &config.baseline_model).is_none() { - rows.push(Row::Separator); - rows.push(label( - "Savings need complete recorded costs, answer usage, and baseline rates", - )); - } - rows -} - -fn period(name: &str, totals: &Totals, config: &Config) -> Vec { - let requests = totals.requests(); - let mut rows = vec![label(format!( - "{name} — {requests} request{} · {} tokens", - if requests == 1 { "" } else { "s" }, - tokens(totals.tokens()) - ))]; - if let Some(savings) = estimate(totals, &config.prices, &config.baseline_model) { - rows.push(label(format!(" Estimated savings {}", 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(); - // Largest share first; model id breaks ties so the order is stable. - 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)| { - label(format!( - " {model} — {:.0}%", - count as f64 / total as f64 * 100.0 - )) - }) - .collect() -} - -/// 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), - } -} - -/// 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}") - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::rollup::ModelTokens; - use switchyard_costs::{CostSource, Pricing, RecordedCost}; - - fn counted(input: u64, output: u64, requests: u64) -> ModelTokens { - ModelTokens { - requests, - input, - cached_input: 0, - output, - ..ModelTokens::default() - } - } - - fn config(prices: Pricing) -> Config { - Config { - baseline_model: "sol".to_string(), - prices, - ..Config::default() - } - } - - fn priced() -> Pricing { - Pricing::parse(br#"{"sol":{"input":1.25,"cache_read":1.25,"cache_write":1.25,"output":10},"luna":{"input":0.25,"cache_read":0.25,"cache_write":0.25,"output":2}}"#).expect("rates") - } - - fn labels(rows: &[Row]) -> Vec { - rows.iter() - .filter_map(|row| match row { - Row::Label(text) => Some(text.clone()), - Row::Separator => None, - }) - .collect() - } - - fn usage_of(today: Totals, week: Totals) -> Usage { - Usage { today, week } - } - - #[test] - fn shows_requests_tokens_and_savings_for_both_periods() { - let mut today = Totals::default(); - today - .routed - .insert("luna".to_string(), counted(1_000_000, 100_000, 3)); - let mut week = Totals::default(); - week.routed - .insert("luna".to_string(), counted(4_000_000, 400_000, 12)); - - today.cost.add(Some(RecordedCost { - usd: 0.45, - source: CostSource::Estimated, - })); - week.cost.add(Some(RecordedCost { - usd: 1.80, - source: CostSource::Estimated, - })); - let rows = build( - ServerStatus::Running, - &usage_of(today, week), - &config(priced()), - ); - let labels = labels(&rows); - - assert_eq!(labels[0], "Server: running · http://127.0.0.1:4123"); - assert_eq!(labels[1], "Today — 3 requests · 1.1M tokens"); - assert_eq!(labels[2], " Estimated savings $1.80 (80% of $2.25)"); - assert_eq!(labels[3], "This week — 12 requests · 4.4M tokens"); - assert_eq!(labels[4], " Estimated savings $7.20 (80% of $9.00)"); - } - - #[test] - fn hides_savings_and_explains_why_when_prices_are_missing() { - let mut week = Totals::default(); - week.routed - .insert("luna".to_string(), counted(1_000, 100, 1)); - - week.classifier - .insert("sol".to_string(), counted(100, 0, 1)); - week.cost.add(None); - for prices in [Pricing::default(), priced()] { - let rows = build( - ServerStatus::Running, - &usage_of(week.clone(), week.clone()), - &config(prices), - ); - let labels = labels(&rows); - assert!( - labels - .iter() - .all(|label| !label.contains("Estimated savings")) - ); - assert!(labels.iter().any(|label| label.contains("costs"))); - } - } - - #[test] - fn ranks_models_by_token_share() { - let mut week = Totals::default(); - week.routed - .insert("luna".to_string(), counted(700_000, 0, 7)); - week.routed - .insert("sol".to_string(), counted(300_000, 0, 3)); - - let rows = build( - ServerStatus::Running, - &usage_of(Totals::default(), week), - &config(priced()), - ); - let models: Vec<_> = labels(&rows) - .into_iter() - .filter(|label| label.starts_with(" ") && label.ends_with('%')) - .collect(); - - assert_eq!(models, vec![" luna — 70%", " sol — 30%"]); - } - - #[test] - fn reports_a_stopped_server_with_no_traffic() { - let rows = build(ServerStatus::Stopped, &Usage::default(), &config(priced())); - let labels = labels(&rows); - - assert!(labels[0].starts_with("Server: not responding")); - assert_eq!(labels[1], "No requests recorded yet"); - } - - #[test] - fn shows_a_loss_when_routing_overhead_exceeds_the_saving() { - let mut today = Totals::default(); - today.routed.insert("sol".to_string(), counted(1_000, 0, 1)); - today - .classifier - .insert("sol".to_string(), counted(1_000_000, 0, 1)); - - today.cost.add(Some(RecordedCost { - usd: 1.25125, - source: CostSource::Estimated, - })); - let rows = build( - ServerStatus::Running, - &usage_of(today.clone(), today), - &config(priced()), - ); - - assert!(labels(&rows)[2].starts_with(" Estimated savings -$1.25")); - } - - #[test] - fn formats_tokens_and_money_for_reading() { - assert_eq!(tokens(412), "412"); - assert_eq!(tokens(12_345), "12K"); - assert_eq!(tokens(1_400_000), "1.4M"); - assert_eq!(tokens(9_700_000_000), "9.7B"); - - assert_eq!(money(2.181), "$2.18"); - assert_eq!(money(-0.125), "-$0.12"); - } -} diff --git a/crates/switchyard-menubar/src/tray.rs b/crates/switchyard-menubar/src/tray.rs deleted file mode 100644 index 7a39c87dab..0000000000 --- a/crates/switchyard-menubar/src/tray.rs +++ /dev/null @@ -1,192 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! The macOS status item: event loop, menu, and the menu's actions. -//! -//! Everything platform-specific lives here, so the rest of the crate builds -//! and is tested on any target. - -use std::path::PathBuf; -use std::process::Command; -use std::thread; -use std::time::{Duration, Instant}; - -use objc2::rc::autoreleasepool; -use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSEventMask}; -use objc2_foundation::{MainThreadMarker, NSDate, NSDefaultRunLoopMode}; -use tray_icon::menu::{IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem}; -use tray_icon::{Icon, TrayIconBuilder}; - -use crate::app::refresh; -use crate::config::Config; -use crate::icon; -use crate::summary::Row; - -const RESTART: &str = "restart-server"; -const OPEN_CONFIG: &str = "open-config"; -const OPEN_SETTINGS: &str = "open-settings"; -const QUIT: &str = "quit"; - -/// How long the loop blocks waiting for a UI event before checking the clock. -const EVENT_POLL: f64 = 0.1; - -/// Runs the status item until the user quits. -pub fn run(config: Config, settings_path: PathBuf) -> Result<(), String> { - let mtm = MainThreadMarker::new().ok_or("the menu bar must run on the main thread")?; - let ns_app = NSApplication::sharedApplication(mtm); - // Accessory keeps the process out of the Dock and the app switcher. - ns_app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); - - let mut log = crate::rollup::Reader::default(); - let glyph = Icon::from_rgba(icon::glyph(), icon::SIZE, icon::SIZE) - .map_err(|error| format!("build icon: {error}"))?; - let tray = TrayIconBuilder::new() - .with_icon(glyph) - .with_icon_as_template(true) - .with_tooltip("Switchyard") - .with_menu(Box::new(menu(&refresh(&config, &mut log))?)) - .build() - .map_err(|error| format!("create status item: {error}"))?; - - ns_app.finishLaunching(); - - let interval = Duration::from_secs(config.refresh_seconds.max(5)); - let mut next = Instant::now() + interval; - let mut actions = Vec::new(); - loop { - let should_quit = autoreleasepool(|_| -> Result { - pump_events(&ns_app); - - let mut redraw = false; - while let Ok(event) = MenuEvent::receiver().try_recv() { - let action = match event.id.as_ref() { - QUIT => return Ok(true), - RESTART => { - let label = config.launchd_label.clone(); - thread::spawn(move || restart_server(&label)) - } - OPEN_CONFIG | OPEN_SETTINGS => { - let path = if event.id.as_ref() == OPEN_CONFIG { - config.config_file.clone() - } else { - settings_path.clone() - }; - thread::spawn(move || open(&path)) - } - _ => continue, - }; - actions.push(action); - } - for index in (0..actions.len()).rev() { - if actions[index].is_finished() { - report( - actions - .swap_remove(index) - .join() - .unwrap_or_else(|_| Err("menu action thread panicked".to_string())), - ); - redraw = true; - } - } - - if redraw || Instant::now() >= next { - tray.set_menu(Some(Box::new(menu(&refresh(&config, &mut log))?))); - next = Instant::now() + interval; - } - Ok(false) - })?; - if should_quit { - return Ok(()); - } - } -} - -/// Restarts the server's LaunchAgent. -fn restart_server(label: &str) -> Result<(), String> { - let uid = command("id", &["-u"])?; - let target = format!("gui/{}/{}", uid.trim(), label); - command("launchctl", &["kickstart", "-k", &target]).map(|_| ()) -} - -/// Opens a config file in the user's editor. -fn open(path: &std::path::Path) -> Result<(), String> { - command("open", &["-t", &path.display().to_string()]).map(|_| ()) -} - -/// Runs a command, returning its stdout or a message naming what failed. -fn command(program: &str, args: &[&str]) -> Result { - let output = Command::new(program) - .args(args) - .output() - .map_err(|error| format!("run {program}: {error}"))?; - if !output.status.success() { - return Err(format!( - "{program} {}: {}", - args.join(" "), - String::from_utf8_lossy(&output.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) -} - -fn report(result: Result<(), String>) { - if let Err(error) = result { - eprintln!("switchyard-menubar: {error}"); - } -} - -/// Builds the whole menu: the summary rows, then the fixed actions. -fn menu(rows: &[Row]) -> Result { - let menu = Menu::new(); - let append = |item: &dyn IsMenuItem| { - menu.append(item) - .map_err(|error| format!("build menu: {error}")) - }; - - for row in rows { - match row { - Row::Separator => append(&PredefinedMenuItem::separator())?, - // Rows are labels, not commands, so they are disabled. - Row::Label(text) => append(&MenuItem::new(text, false, None))?, - } - } - append(&PredefinedMenuItem::separator())?; - for (id, text) in [ - (RESTART, "Restart server"), - (OPEN_CONFIG, "Open server config…"), - (OPEN_SETTINGS, "Open menu bar settings…"), - (QUIT, "Quit"), - ] { - append(&MenuItem::with_id(id, text, true, None))?; - } - Ok(menu) -} - -/// Drains pending AppKit events, blocking briefly when there are none. -fn pump_events(ns_app: &NSApplication) { - let mut expiration = Some(NSDate::dateWithTimeIntervalSinceNow(EVENT_POLL)); - while let Some(event) = unsafe { - ns_app.nextEventMatchingMask_untilDate_inMode_dequeue( - NSEventMask::Any, - expiration.as_deref(), - NSDefaultRunLoopMode, - true, - ) - } { - ns_app.sendEvent(&event); - // Only the first wait blocks; the rest drain what is already queued. - expiration = Some(NSDate::distantPast()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reports_which_command_failed() { - let error = command("switchyard-does-not-exist", &[]).expect_err("missing program"); - - assert!(error.contains("switchyard-does-not-exist")); - } -} diff --git a/crates/switchyard-server/README.md b/crates/switchyard-server/README.md index 387a9e99f2..b6331285c3 100644 --- a/crates/switchyard-server/README.md +++ b/crates/switchyard-server/README.md @@ -156,8 +156,9 @@ at startup, and saved costs do not change when prices change. An unreadable or i `--pricing-file` stops startup; the server does not use bundled rates instead. The [pi guide](../../docs/integrations/pi.md#estimate-the-cost) explains how to supply -rates for your own models. The menubar reads the same file through its `pricing_file` -setting when you override the bundled rates; its actual spending comes from recorded costs. +rates for your own models. The desktop app uses the same bundled rates by default. +For custom rates, point its `pricing_file` setting at the same TOML or JSON file as +the server's `--pricing-file`. Actual spending comes from recorded costs. Clients can send `x-switchyard-origin: codex-cli` (or another client label) to include an `origin` field in each routing record. Missing, empty, or non-text header values produce @@ -294,7 +295,7 @@ rule to `~/.codex/rules/default.rules`. See Codex's [Agent approvals & security](https://learn.chatgpt.com/docs/agent-approvals-security) page. -The installer, `scripts/linux/install.sh`, does not overwrite an existing +The installer, `cargo desktop install`, does not overwrite an existing `~/.switchyard/composite.toml`. If you installed Switchyard before this route was added, add the `[targets.reviewer]` and `[routes.codex_auto_review]` blocks to that file by @@ -319,8 +320,8 @@ hand, then restart the server. | `GET` | `/health` | Liveness | Requests name a route by its `id`, so `POST /v1/chat/completions` with `"model": "switchyard/general"` -routes through the `[routes.general]` entry above. Any of the three request formats can address any -route, and the server translates between them. +routes through the `[routes.general]` entry above. The server translates between request formats +supported by the selected route and its forwarding clients. Set the top-level `fallback_client` to an entry under `[llm_clients]` to proxy any otherwise unmatched method and path through that client. The fallback client does not need a target. Requests diff --git a/scripts/common.sh b/scripts/common.sh deleted file mode 100644 index cd27254f16..0000000000 --- a/scripts/common.sh +++ /dev/null @@ -1,117 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -SY_HOME="${SY_HOME:-$HOME/.switchyard}" -SY_PORT="${SY_PORT:-4123}" -CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" -# Both installers use Codex's standalone profile file for the CLI. -CODEX_PROFILE_CONFIG="$CODEX_DIR/sy.config.toml" -ALIAS_START="# >>> switchyard codex alias >>>" -ALIAS_END="# <<< switchyard codex alias <<<" - -say() { printf '%s\n' "$*"; } -step() { printf '\n==> %s\n' "$*"; } - -strip_block() { - local path="$1" start="$2" end="$3" label="${4:-the switchyard block}" - if [[ ! -f "$path" ]] || ! grep -qF "$start" "$path"; then - return 1 - fi - if ! grep -qF "$end" "$path"; then - say " $path has no end marker for $label; not editing it" >&2 - return 1 - fi - if (( DRY_RUN )); then - say " would remove $label from $path" - return 0 - fi - local temp - temp="$(mktemp)" - awk -v start="$start" -v end="$end" ' - index($0, start) { skipping = 1 } - !skipping { print } - index($0, end) { skipping = 0 } - END { if (skipping) exit 1 } - ' "$path" > "$temp" - cat "$temp" > "$path" - rm -f "$temp" - say " removed $label from $path" - return 0 -} - -run() { - if (( DRY_RUN )); then - say " would run: $*" - else - "$@" - fi -} - -write_once() { - local path="$1" - if [[ -f "$path" ]]; then - say " keeping existing $path" - cat >/dev/null - return - fi - if (( DRY_RUN )); then - say " would create $path" - cat >/dev/null - else - mkdir -p "$(dirname "$path")" - cat > "$path" - say " created $path" - fi -} - -write_with_backup() { - local path="$1" - if (( DRY_RUN )); then - [[ -f "$path" ]] && say " would back up $path" - say " would write $path" - cat >/dev/null - return - fi - mkdir -p "$(dirname "$path")" - local incoming - incoming="$(mktemp)" - cat > "$incoming" - if [[ -f "$path" ]] && cmp -s "$incoming" "$path"; then - say " $path is already up to date" - rm -f "$incoming" - return - fi - if [[ -f "$path" ]]; then - local backup - backup="$path.switchyard-backup.$(date +%Y%m%d%H%M%S)" - cp "$path" "$backup" - say " backed up $path to $backup" - fi - cat "$incoming" > "$path" - rm -f "$incoming" - say " wrote $path" -} - -write_always() { - local path="$1" - if (( DRY_RUN )); then - say " would write $path" - cat >/dev/null - else - mkdir -p "$(dirname "$path")" - cat > "$path" - say " wrote $path" - fi -} - -remove_file() { - local path="$1" - if [[ ! -e "$path" ]]; then - say " nothing to remove at $path" - elif (( DRY_RUN )); then - say " would delete $path" - else - rm -f "$path" - say " deleted $path" - fi -} diff --git a/scripts/linux/common.sh b/scripts/linux/common.sh deleted file mode 100644 index 3a9b7782e1..0000000000 --- a/scripts/linux/common.sh +++ /dev/null @@ -1,8 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# shellcheck source=scripts/common.sh -source "$(dirname "${BASH_SOURCE[0]}")/../common.sh" - -SERVICE_NAME="switchyard.service" -SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" diff --git a/scripts/linux/install.sh b/scripts/linux/install.sh deleted file mode 100755 index c296ebcfc1..0000000000 --- a/scripts/linux/install.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Installs the Switchyard background server as a systemd --user service and sets -# up a `sy` Codex profile. -# -# Keeps existing composite.toml, replaces the service unit, and backs up -# sy.config.toml before replacing it. Use systemctl --user edit switchyard -# for service changes that survive reinstalling. -# Run with --dry-run to print what would happen. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" - -DRY_RUN=0 -case "$#:${1:-}" in - 0:) ;; - 1:--dry-run) DRY_RUN=1 ;; - *) printf 'Usage: %s [--dry-run]\n' "$0" >&2; exit 2 ;; -esac - -# shellcheck source=scripts/linux/common.sh -source "$SCRIPT_DIR/common.sh" - -if [[ "$SY_HOME" =~ [[:space:][:cntrl:]] || "$SY_HOME" == *\\ ]]; then - say "SY_HOME must not contain whitespace, control characters, or a trailing backslash." >&2 - exit 1 -fi -if [[ ! "$SY_PORT" =~ ^[0-9]+$ ]]; then - say "SY_PORT must contain only digits." >&2 - exit 1 -fi - -if [[ "$(uname -s)" != "Linux" ]]; then - say "This installer is for Linux only." >&2 - exit 1 -fi - -step "Building release binary" -run cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" -p switchyard-server - -step "Installing binary into $SY_HOME/bin" -run mkdir -p "$SY_HOME/bin" -run install -m 755 "$REPO_ROOT/target/release/switchyard-server" "$SY_HOME/bin/switchyard-server" - -step "Writing server config" -write_once "$SY_HOME/composite.toml" < "$REPO_ROOT/scripts/config/composite.toml" - -step "Validating the server config" -if (( DRY_RUN )); then - say " would run: $SY_HOME/bin/switchyard-server --config $SY_HOME/composite.toml --dry-run" -else - "$SY_HOME/bin/switchyard-server" --config "$SY_HOME/composite.toml" --dry-run -fi - -step "Writing the systemd user service" -write_always "$SYSTEMD_USER_DIR/$SERVICE_NAME" <&2 - exit 1 -fi - -step "Adding the sy Codex profile" -# This profile only changes which router answers. Approval and sandbox -# settings are deliberately left out, so the profile cannot loosen how Codex -# asks before it acts. Set those yourself if you want them. -sed "s/@SY_PORT@/$SY_PORT/g" "$REPO_ROOT/scripts/config/codex.sy.toml" | write_with_backup "$CODEX_PROFILE_CONFIG" - -step "Done" -say "Server: http://127.0.0.1:$SY_PORT" -say "Logs: journalctl --user -u switchyard" -say "Manage it with: systemctl --user {status,restart,stop} $SERVICE_NAME" -say "Use it with: codex -p sy (requires Codex CLI 0.134.0 or newer)" -say "" -say "If you want the server running even when you are logged out, run:" -say " loginctl enable-linger \$USER" diff --git a/scripts/linux/uninstall.sh b/scripts/linux/uninstall.sh deleted file mode 100755 index f968a030d1..0000000000 --- a/scripts/linux/uninstall.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Removes what install.sh added: the systemd user service, the `sy` Codex -# profile, and any codex alias from an older install. Config and binaries stay -# put; the paths are printed so you can delete them yourself. -# -# Run with --dry-run to print what would happen. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -DRY_RUN=0 -case "$#:${1:-}" in - 0:) ;; - 1:--dry-run) DRY_RUN=1 ;; - *) printf 'Usage: %s [--dry-run]\n' "$0" >&2; exit 2 ;; -esac - -# shellcheck source=scripts/linux/common.sh -source "$SCRIPT_DIR/common.sh" - -step "Removing the sy Codex profile" -remove_file "$CODEX_PROFILE_CONFIG" - -step "Removing the codex alias" -for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do - if [[ -f "$rc" ]] && grep -qF "$ALIAS_START" "$rc"; then - strip_block "$rc" "$ALIAS_START" "$ALIAS_END" "the codex alias" - else - say " no alias in $rc" - fi -done - -step "Stopping the systemd user service" -if (( DRY_RUN )); then - say " would run: systemctl --user disable --now $SERVICE_NAME" - say " would delete $SYSTEMD_USER_DIR/$SERVICE_NAME" -else - systemctl --user disable --now "$SERVICE_NAME" 2>/dev/null || true - rm -f "$SYSTEMD_USER_DIR/$SERVICE_NAME" - systemctl --user daemon-reload - say " stopped and removed $SERVICE_NAME" -fi - -step "Done" -say "Left in place, delete them if you want:" -say " $SY_HOME (binary, config, routing log)" -say " $CODEX_PROFILE_CONFIG.switchyard-backup.* (backups taken at install time)" diff --git a/scripts/macos/common.sh b/scripts/macos/common.sh deleted file mode 100644 index 9978601f41..0000000000 --- a/scripts/macos/common.sh +++ /dev/null @@ -1,46 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Paths, markers, and helpers shared by install.sh and uninstall.sh. -# The markers must match between the two, which is why they live here. - -# shellcheck source=scripts/common.sh -source "$(dirname "${BASH_SOURCE[0]}")/../common.sh" - -SERVER_LABEL="com.nvidia.switchyard.server" -MENUBAR_LABEL="com.nvidia.switchyard.menubar" -LAUNCH_AGENTS="$HOME/Library/LaunchAgents" -CODEX_CONFIG="$CODEX_DIR/config.toml" -# Codex reads `--profile sy` from its own file next to config.toml. A -# [profiles.sy] table in config.toml is rejected outright as legacy config. -# Codex.app ignores profiles entirely, so it gets a whole config.toml with the -# routing inlined. Keep the generated file beside the original for reference. -CODEX_SWITCHYARD_CONFIG="$CODEX_DIR/config.sy.toml" -CODEX_DIRECT_CONFIG="$CODEX_DIR/config.toml.direct" -PROFILE_START="# >>> switchyard sy profile >>>" -PROFILE_END="# <<< switchyard sy profile <<<" - -codex_config_uses_switchyard() { - local path="$1" - [[ -f "$path" ]] || return 1 - awk ' - function is_string(value, wanted, quote, prefix, suffix) { - sub(/^[[:space:]]*/, "", value) - quote = substr(value, 1, 1) - if (quote != "\"" && quote != sprintf("%c", 39)) return 0 - prefix = quote wanted quote - if (substr(value, 1, length(prefix)) != prefix) return 0 - suffix = substr(value, length(prefix) + 1) - return suffix ~ /^[[:space:]]*(#.*)?$/ - } - BEGIN { top_level = 1 } - /^[[:space:]]*#/ { next } - /^[[:space:]]*\[/ { top_level = 0; next } - top_level && /^[[:space:]]*model_provider[[:space:]]*=/ { - value = $0 - sub(/^[^=]*=[[:space:]]*/, "", value) - if (is_string(value, "sy")) found = 1 - } - END { exit !found } - ' "$path" -} diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh deleted file mode 100755 index 8fb6e2a920..0000000000 --- a/scripts/macos/install.sh +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Installs the Switchyard background server and its menu bar companion as -# per-user LaunchAgents and sets up a `sy` Codex profile. -# -# Keeps existing server and menu bar settings and backs up sy.config.toml -# before replacing it. -# Run with --dry-run to print what would happen. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" - -DRY_RUN=0 -case "$#:${1:-}" in - 0:) ;; - 1:--dry-run) DRY_RUN=1 ;; - *) printf 'Usage: %s [--dry-run]\n' "$0" >&2; exit 2 ;; -esac - -# shellcheck source=scripts/macos/common.sh -source "$SCRIPT_DIR/common.sh" - -xml_escape_text() { - printf '%s' "$1" | sed \ - -e 's/&/\&/g' \ - -e 's//\>/g' -} - -toml_escape_basic_string() { - printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -} - -if [[ "$(uname -s)" != "Darwin" ]]; then - say "This installer is for macOS only." >&2 - exit 1 -fi - -step "Building release binaries" -run cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" \ - -p switchyard-server -p switchyard-menubar - -step "Installing binaries into $SY_HOME/bin" -run mkdir -p "$SY_HOME/bin" "$SY_HOME/logs" -for binary in switchyard-server switchyard-menubar; do - run install -m 755 "$REPO_ROOT/target/release/$binary" "$SY_HOME/bin/$binary" -done - -step "Writing server config" -# Keep macOS and Linux on the same routing defaults. -write_once "$SY_HOME/composite.toml" < "$REPO_ROOT/scripts/config/composite.toml" - -step "Writing menu bar settings" -# Existing price files override the bundled table for both applications. -TOML_SY_HOME="$(toml_escape_basic_string "$SY_HOME")" -XML_SY_HOME="$(xml_escape_text "$SY_HOME")" -PRICING_SETTING="" -PRICING_XML="" -SERVER_ARGS=( - --config "$SY_HOME/composite.toml" - --routing-log-file "$SY_HOME/routing.jsonl" -) -if [[ -e "$SY_HOME/prices.json" ]]; then - SERVER_ARGS+=(--pricing-file "$SY_HOME/prices.json") - PRICING_SETTING="pricing_file = \"$TOML_SY_HOME/prices.json\"" - PRICING_XML="--pricing-file - $XML_SY_HOME/prices.json" -fi -write_once "$SY_HOME/menubar.toml" < - - - - Label - $SERVER_LABEL - ProgramArguments - - $XML_SY_HOME/bin/switchyard-server - --config - $XML_SY_HOME/composite.toml - --host - 127.0.0.1 - --port - $SY_PORT - --routing-log-file - $XML_SY_HOME/routing.jsonl - $PRICING_XML - - RunAtLoad - - KeepAlive - - ProcessType - Background - StandardOutPath - $XML_SY_HOME/logs/server.log - StandardErrorPath - $XML_SY_HOME/logs/server.err.log - EnvironmentVariables - - RUST_LOG - info - - - -EOF - -# SuccessfulExit false means the Quit menu item stays quit, while a crash is -# still restarted. Aqua-only, since there is no menu bar without a login session. -write_always "$LAUNCH_AGENTS/$MENUBAR_LABEL.plist" < - - - - Label - $MENUBAR_LABEL - ProgramArguments - - $XML_SY_HOME/bin/switchyard-menubar - $XML_SY_HOME/menubar.toml - - RunAtLoad - - KeepAlive - - SuccessfulExit - - - LimitLoadToSessionType - Aqua - StandardOutPath - $XML_SY_HOME/logs/menubar.log - StandardErrorPath - $XML_SY_HOME/logs/menubar.err.log - - -EOF - -step "Loading LaunchAgents" -for label in "$SERVER_LABEL" "$MENUBAR_LABEL"; do - if (( DRY_RUN )); then - say " would reload gui/$UID/$label" - continue - fi - launchctl bootout "gui/$UID/$label" 2>/dev/null || true - # bootout returns before the job leaves the domain, and bootstrapping a - # service that is still shutting down fails with an I/O error. - for _ in $(seq 50); do - launchctl print "gui/$UID/$label" >/dev/null 2>&1 || break - sleep 0.1 - done - launchctl bootstrap "gui/$UID" "$LAUNCH_AGENTS/$label.plist" - # macOS can defer RunAtLoad jobs until an explicit start is requested. - launchctl kickstart "gui/$UID/$label" - say " started $label" -done - -step "Adding the sy Codex profile" -# This profile only changes which router answers. Approval and sandbox -# settings are deliberately left out, so the profile cannot loosen how Codex -# asks before it acts. Set those yourself if you want them. -sed "s/@SY_PORT@/$SY_PORT/g" "$REPO_ROOT/scripts/config/codex.sy.toml" | - write_with_backup "$CODEX_PROFILE_CONFIG" - -step "Done" -say "Server: http://127.0.0.1:$SY_PORT (logs in $SY_HOME/logs)" -say "Use it with: codex -p sy (requires Codex CLI 0.134.0 or newer)" -say "Settings: $SY_HOME/menubar.toml" -say "Look for the Switchyard glyph in the menu bar." diff --git a/scripts/macos/uninstall.sh b/scripts/macos/uninstall.sh deleted file mode 100755 index 66f670e17b..0000000000 --- a/scripts/macos/uninstall.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Removes what install.sh added: the two LaunchAgents, the `sy` Codex profile, -# and the codex alias. Your config, routing log, and binaries stay put; the -# paths are printed so you can delete them yourself. -# -# Run with --dry-run to print what would happen. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -DRY_RUN=0 -case "$#:${1:-}" in - 0:) ;; - 1:--dry-run) DRY_RUN=1 ;; - *) printf 'Usage: %s [--dry-run]\n' "$0" >&2; exit 2 ;; -esac - -# shellcheck source=scripts/macos/common.sh -source "$SCRIPT_DIR/common.sh" - -step "Unloading LaunchAgents" -for label in "$SERVER_LABEL" "$MENUBAR_LABEL"; do - if (( DRY_RUN )); then - say " would unload gui/$UID/$label and delete its plist" - else - launchctl bootout "gui/$UID/$label" 2>/dev/null || true - rm -f "$LAUNCH_AGENTS/$label.plist" - say " unloaded $label" - fi -done - -step "Removing the sy Codex profile" -remove_file "$CODEX_PROFILE_CONFIG" -remove_file "$CODEX_SWITCHYARD_CONFIG" -# Older installs of this script put the profile in config.toml instead. -strip_block "$CODEX_CONFIG" "$PROFILE_START" "$PROFILE_END" "the legacy sy profile" || - say " no legacy profile in $CODEX_CONFIG" - -step "Unrouting Codex.app" -if [[ -f "$CODEX_DIRECT_CONFIG" ]]; then - if (( DRY_RUN )); then - say " would preserve $CODEX_CONFIG before restoring $CODEX_DIRECT_CONFIG" - else - backup="" - if [[ -f "$CODEX_CONFIG" ]]; then - backup="$(mktemp "$CODEX_CONFIG.switchyard-current.XXXXXX")" - cp "$CODEX_CONFIG" "$backup" - fi - cp "$CODEX_DIRECT_CONFIG" "$CODEX_CONFIG" - rm -f "$CODEX_DIRECT_CONFIG" - say " restored the original config.toml${backup:+; preserved the current file at $backup}" - fi -elif codex_config_uses_switchyard "$CODEX_CONFIG"; then - say " config.toml is routed but $CODEX_DIRECT_CONFIG is missing; edit it by hand" -else - remove_file "$CODEX_DIRECT_CONFIG" -fi - -step "Removing the codex alias" -for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do - strip_block "$rc" "$ALIAS_START" "$ALIAS_END" "the codex alias" || - say " no alias in $rc" -done - -step "Done" -say "Left in place, delete them if you want:" -say " $SY_HOME (binaries, config, routing log, logs)" -say " $CODEX_CONFIG.switchyard-current.* (configs preserved during restore)" -say " $CODEX_PROFILE_CONFIG.switchyard-backup.* (profile backups)" diff --git a/tests/test_linux_install.py b/tests/test_linux_install.py deleted file mode 100644 index 7d070c8e35..0000000000 --- a/tests/test_linux_install.py +++ /dev/null @@ -1,197 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -import os -import shutil -import subprocess -from pathlib import Path - -import pytest - -REPO = Path(__file__).resolve().parents[1] -START = "# >>> switchyard codex alias >>>" -END = "# <<< switchyard codex alias <<<" -RC = f"before\n{START}\nalias codex='codex -p sy'\n{END}\nafter\n" - - -@pytest.fixture -def setup(tmp_path): - scripts = tmp_path / "repo" / "scripts" / "linux" - shutil.copytree(REPO / "scripts" / "linux", scripts) - shutil.copy(REPO / "scripts" / "common.sh", scripts.parent / "common.sh") - shutil.copytree(REPO / "scripts" / "config", scripts.parent / "config") - home = tmp_path / "home" - home.mkdir() - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - env = { - **os.environ, - "HOME": str(home), - "SY_HOME": str(home / ".switchyard"), - "SY_PORT": "4123", - "XDG_CONFIG_HOME": str(home / ".config"), - "CODEX_HOME": str(home / ".codex"), - "TMPDIR": str(tmp_path), - "PATH": f"{bin_dir}:/usr/bin:/bin", - "SYSTEMCTL_LOG": str(tmp_path / "systemctl.log"), - "FAIL_SYSTEMCTL": "", - } - stubs = { - "cargo": "exit 0\n", - "uname": "echo Linux\n", - "sleep": "exit 0\n", - "install": 'printf "#!/bin/sh\\nexit 0\\n" > "$4"\nchmod +x "$4"\n', - "systemctl": 'echo "$*" >> "$SYSTEMCTL_LOG"\n' - '[[ "$*" != *"$FAIL_SYSTEMCTL"* || -z "$FAIL_SYSTEMCTL" ]]\n', - } - for name, body in stubs.items(): - stub = bin_dir / name - stub.write_text("#!/bin/bash\nset -eu\n" + body) - stub.chmod(0o755) - return scripts, home, bin_dir, env - - -def run(setup, script, *args): - scripts, _, _, env = setup - return subprocess.run( - ["bash", str(scripts / script), *args], - env=env, - capture_output=True, - text=True, - timeout=10, - ) - - -@pytest.mark.parametrize("script", ["install.sh", "uninstall.sh"]) -@pytest.mark.parametrize("args", [["--dryrun"], ["--help"], ["-n"], ["--dry-run", "extra"], [""]]) -def test_bad_arguments_leave_files_unchanged(setup, script, args): - _, home, _, env = setup - rc = home / ".bashrc" - rc.write_text(RC) - result = run(setup, script, *args) - assert result.returncode == 2 - assert "Usage:" in result.stderr - assert rc.read_text() == RC - assert not Path(env["SYSTEMCTL_LOG"]).exists() - - -@pytest.mark.parametrize("failure", ["mktemp", "awk", "missing-end", "second-missing-end"]) -def test_uninstall_failure_preserves_shell_files(setup, failure): - _, home, bin_dir, _ = setup - contents = RC - if failure == "missing-end": - contents = RC.replace(END + "\n", "") - elif failure == "second-missing-end": - contents += f"{START}\nalias codex='codex -p sy'\ntail\n" - else: - (bin_dir / failure).write_text("#!/bin/bash\nexit 1\n") - (bin_dir / failure).chmod(0o755) - for name in [".zshrc", ".bashrc"]: - (home / name).write_text(contents) - result = run(setup, "uninstall.sh") - assert result.returncode != 0 - for name in [".zshrc", ".bashrc"]: - assert (home / name).read_text() == contents - - -def test_uninstall_cleans_profile_and_aliases_before_systemctl_failure(setup): - _, home, _, env = setup - env["FAIL_SYSTEMCTL"] = "--user" - profile = Path(env["CODEX_HOME"]) / "sy.config.toml" - profile.parent.mkdir() - profile.write_text("old profile\n") - for name in [".zshrc", ".bashrc"]: - (home / name).write_text(RC) - result = run(setup, "uninstall.sh") - assert result.returncode != 0 - assert not profile.exists() - for name in [".zshrc", ".bashrc"]: - assert (home / name).read_text() == "before\nafter\n" - - -@pytest.mark.parametrize("script", ["install.sh", "uninstall.sh"]) -def test_dry_run_leaves_files_unchanged(setup, script): - _, home, _, env = setup - (home / ".bashrc").write_text(RC) - result = run(setup, script, "--dry-run") - assert result.returncode == 0, result.stderr - assert (home / ".bashrc").read_text() == RC - assert sorted(path.name for path in home.iterdir()) == [".bashrc"] - assert not Path(env["SYSTEMCTL_LOG"]).exists() - - -@pytest.mark.parametrize( - "key,value", - [ - ("SY_HOME", "/tmp/with space"), - ("SY_HOME", "/tmp/line\nbreak"), - ("SY_HOME", "/tmp/control\x01"), - ("SY_HOME", "/tmp/backslash\\"), - ("SY_PORT", "bad"), - ("SY_PORT", "4123\n"), - ], -) -def test_invalid_unit_values_fail_before_install(setup, key, value): - _, home, _, env = setup - env[key] = value - result = run(setup, "install.sh") - assert result.returncode != 0 - assert key in result.stderr - assert not list(home.iterdir()) - - -def test_reinstall_restarts_service_and_keeps_user_config(setup): - _, home, _, env = setup - (home / ".bashrc").write_text("user settings\n") - assert run(setup, "install.sh").returncode == 0 - config = Path(env["SY_HOME"]) / "composite.toml" - config.write_text("user config\n") - env["SY_PORT"] = "5000" - result = run(setup, "install.sh") - assert result.returncode == 0, result.stderr - assert config.read_text() == "user config\n" - assert (home / ".bashrc").read_text() == "user settings\n" - assert not (home / ".zshrc").exists() - profile = Path(env["CODEX_HOME"]) / "sy.config.toml" - assert "127.0.0.1:5000/v1" in profile.read_text() - backups = list(profile.parent.glob("sy.config.toml.switchyard-backup.*")) - assert len(backups) == 1 - assert "127.0.0.1:4123/v1" in backups[0].read_text() - calls = Path(env["SYSTEMCTL_LOG"]).read_text().splitlines() - assert ( - calls - == [ - "--user daemon-reload", - "--user enable switchyard.service", - "--user restart switchyard.service", - "--user is-active --quiet switchyard.service", - ] - * 2 - ) - - -def test_failed_start_leaves_existing_profile_unchanged(setup): - _, _, _, env = setup - env["FAIL_SYSTEMCTL"] = "is-active" - profile = Path(env["CODEX_HOME"]) / "sy.config.toml" - profile.parent.mkdir() - profile.write_text("user profile\n") - result = run(setup, "install.sh") - assert result.returncode != 0 - assert "journalctl" in result.stderr - assert profile.read_text() == "user profile\n" - - -def test_default_make_only_prints_help(setup): - _, home, _, env = setup - result = subprocess.run( - ["make", "-f", str(REPO / "Makefile")], - cwd=REPO, - env=env, - capture_output=True, - text=True, - timeout=10, - ) - assert result.returncode == 0, result.stderr - assert "install-linux-dry-run" in result.stdout - assert not list(home.iterdir()) diff --git a/tests/test_macos_install.py b/tests/test_macos_install.py deleted file mode 100644 index ccef74a2be..0000000000 --- a/tests/test_macos_install.py +++ /dev/null @@ -1,299 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -import os -import shutil -import subprocess - -try: - import tomllib -except ModuleNotFoundError: # Python 3.10 - import tomli as tomllib -import xml.etree.ElementTree as ET -from pathlib import Path - -import pytest - -REPO = Path(__file__).resolve().parents[1] - - -@pytest.fixture -def setup(tmp_path): - scripts = tmp_path / "repo" / "scripts" / "macos" - shutil.copytree(REPO / "scripts" / "macos", scripts) - shutil.copy(REPO / "scripts" / "common.sh", scripts.parent / "common.sh") - shutil.copytree(REPO / "scripts" / "config", scripts.parent / "config") - home = tmp_path / "home" - home.mkdir() - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - switchyard_home = home / "Switchyard & Routing " - env = { - **os.environ, - "HOME": str(home), - "SY_HOME": str(switchyard_home), - "CODEX_HOME": str(home / ".codex"), - "TMPDIR": str(tmp_path), - "PATH": f"{bin_dir}:/usr/bin:/bin", - } - stubs = { - "cargo": "exit 0\n", - "uname": "echo Darwin\n", - "install": 'printf "#!/bin/sh\\nexit 0\\n" > "$4"\nchmod +x "$4"\n', - "launchctl": '[[ "$1" != print ]]\n', - } - for name, body in stubs.items(): - stub = bin_dir / name - stub.write_text("#!/bin/bash\nset -eu\n" + body) - stub.chmod(0o755) - return scripts, home, switchyard_home, env - - -def run(setup, script): - scripts, _, _, env = setup - return subprocess.run( - ["bash", str(scripts / script)], - env=env, - capture_output=True, - text=True, - timeout=10, - ) - - -def read_config(path): - return path.read_text() - - -def test_install_starts_each_launch_agent_after_loading_it(setup): - _, _, _, env = setup - command_log = Path(env["TMPDIR"]) / "launchctl.log" - stub = Path(env["PATH"].split(":")[0]) / "launchctl" - stub.write_text( - '#!/bin/bash\nset -eu\n' - f'printf "%s\\n" "$*" >> "{command_log}"\n' - '[[ "$1" != print ]]\n' - ) - - result = run(setup, "install.sh") - - assert result.returncode == 0, result.stderr - commands = command_log.read_text().splitlines() - for label in ("com.nvidia.switchyard.server", "com.nvidia.switchyard.menubar"): - bootstrap = next( - index for index, command in enumerate(commands) - if command.startswith("bootstrap ") and command.endswith(f"/{label}.plist") - ) - domain = commands[bootstrap].split()[1] - assert commands[bootstrap + 1] == f"kickstart {domain}/{label}" - - -def test_install_escapes_switchyard_path_in_launch_agent(setup): - _, home, switchyard_home, _ = setup - result = run(setup, "install.sh") - assert result.returncode == 0, result.stderr - plist = home / "Library" / "LaunchAgents" / "com.nvidia.switchyard.server.plist" - root = ET.parse(plist).getroot() - entries = list(root.find("dict")) - values = {entries[index].text: entries[index + 1] for index in range(0, len(entries), 2)} - program_arguments = [element.text for element in values["ProgramArguments"]] - assert program_arguments[0] == str(switchyard_home / "bin" / "switchyard-server") - assert program_arguments[2] == str(switchyard_home / "composite.toml") - assert program_arguments[8] == str(switchyard_home / "routing.jsonl") - assert values["StandardOutPath"].text == str(switchyard_home / "logs" / "server.log") - assert values["StandardErrorPath"].text == str(switchyard_home / "logs" / "server.err.log") - - menubar = home / "Library" / "LaunchAgents" / "com.nvidia.switchyard.menubar.plist" - menubar_root = ET.parse(menubar).getroot() - menubar_entries = list(menubar_root.find("dict")) - menubar_values = { - menubar_entries[index].text: menubar_entries[index + 1] - for index in range(0, len(menubar_entries), 2) - } - menubar_arguments = [element.text for element in menubar_values["ProgramArguments"]] - assert menubar_arguments == [ - str(switchyard_home / "bin" / "switchyard-menubar"), - str(switchyard_home / "menubar.toml"), - ] - - -def test_missing_codex_config_creates_only_standalone_profile(setup): - _, home, _, _ = setup - result = run(setup, "install.sh") - assert result.returncode == 0, result.stderr - codex = home / ".codex" - expected = read_config(REPO / "scripts" / "config" / "codex.sy.toml").replace( - "@SY_PORT@", "4123" - ) - assert read_config(codex / "sy.config.toml") == expected - assert {path.name for path in codex.iterdir()} == {"sy.config.toml"} - - -def test_install_prints_profile_usage_without_editing_shell_files(setup): - _, home, _, _ = setup - zshrc = home / ".zshrc" - bashrc = home / ".bashrc" - zshrc.write_text("zsh settings\n") - bashrc.write_text("bash settings\n") - - result = run(setup, "install.sh") - - assert result.returncode == 0, result.stderr - assert "Use it with: codex -p sy" in result.stdout - assert zshrc.read_text() == "zsh settings\n" - assert bashrc.read_text() == "bash settings\n" - - -def test_menu_bar_settings_escape_sy_home_as_toml_strings(setup): - _, home, _, env = setup - switchyard_home = home / 'Switchyard "quoted" \\ folder' - env["SY_HOME"] = str(switchyard_home) - - result = run(setup, "install.sh") - - assert result.returncode == 0, result.stderr - settings = tomllib.loads((switchyard_home / "menubar.toml").read_text()) - assert settings["routing_log"] == str(switchyard_home / "routing.jsonl") - assert settings["config_file"] == str(switchyard_home / "composite.toml") - - -def test_reinstall_backs_up_profile_and_keeps_user_settings(setup): - _, _, switchyard_home, env = setup - result = run(setup, "install.sh") - assert result.returncode == 0, result.stderr - profile = Path(env["CODEX_HOME"]) / "sy.config.toml" - original = profile.read_text() - server_config = switchyard_home / "composite.toml" - menu_settings = switchyard_home / "menubar.toml" - server_config.write_text("user server settings\n") - menu_settings.write_text("user menu settings\n") - env["SY_PORT"] = "5000" - - result = run(setup, "install.sh") - - assert result.returncode == 0, result.stderr - assert "127.0.0.1:5000/v1" in profile.read_text() - backups = list(profile.parent.glob("sy.config.toml.switchyard-backup.*")) - assert len(backups) == 1 - assert backups[0].read_text() == original - assert server_config.read_text() == "user server settings\n" - assert menu_settings.read_text() == "user menu settings\n" - - -@pytest.mark.parametrize( - "provider_header", - [ - "[model_providers.\"sy\"]", - "[model_providers . 'sy']", - '[ "model_providers" . "sy" ]', - ], -) -def test_existing_codex_config_is_preserved_and_shared_templates_are_used( - setup, provider_header -): - _, home, switchyard_home, env = setup - codex = Path(env["CODEX_HOME"]) - codex.mkdir() - config = codex / "config.toml" - original = f'''theme = "dark" - -{provider_header} # old provider -name = "Old" -base_url = "http://old" - -[other] -model_provider = "sy" -''' - config.write_text(original) - result = run(setup, "install.sh") - assert result.returncode == 0, result.stderr - assert read_config(config) == original - profile = read_config(codex / "sy.config.toml") - expected = read_config(REPO / "scripts" / "config" / "codex.sy.toml").replace( - "@SY_PORT@", "4123" - ) - assert profile == expected - assert tomllib.loads(profile)["model_providers"]["sy"]["name"] == "Switchyard" - assert not (codex / "config.sy.toml").exists() - assert read_config(switchyard_home / "composite.toml") == read_config( - REPO / "scripts" / "config" / "composite.toml" - ) - assert not (codex / "config.toml.direct").exists() - - -@pytest.mark.parametrize("quote", ['"', "'"]) -def test_quoted_top_level_provider_keeps_existing_snapshot(setup, quote): - _, home, _, env = setup - codex = Path(env["CODEX_HOME"]) - codex.mkdir() - (codex / "config.toml").write_text(f"model_provider = {quote}sy{quote} # routed\n") - snapshot = codex / "config.toml.direct" - snapshot.write_text("original direct config\n") - result = run(setup, "install.sh") - assert result.returncode == 0, result.stderr - assert snapshot.read_text() == "original direct config\n" - assert (codex / "config.toml").read_text() == f"model_provider = {quote}sy{quote} # routed\n" - assert not (codex / "config.sy.toml").exists() - assert (codex / "sy.config.toml").is_file() - - -def test_uninstall_preserves_routed_config_before_restoring_snapshot(setup): - _, home, _, env = setup - codex = Path(env["CODEX_HOME"]) - codex.mkdir() - current = "model_provider = 'sy' # routed\nuser_setting = \"keep me\"\n" - (codex / "config.toml").write_text(current) - (codex / "config.toml.direct").write_text("model = \"original\"\n") - result = run(setup, "uninstall.sh") - assert result.returncode == 0, result.stderr - assert (codex / "config.toml").read_text() == 'model = "original"\n' - backups = list(codex.glob("config.toml.switchyard-current.*")) - assert len(backups) == 1 - assert backups[0].read_text() == current - - -@pytest.mark.parametrize("script", ["install.sh", "uninstall.sh"]) -@pytest.mark.parametrize( - "args", - [ - ["--dryrun"], - ["unknown"], - [""], - ["--dry-run", "extra"], - ["--dry-run", "--dry-run"], - ], -) -def test_scripts_reject_unknown_arguments_before_any_work(setup, script, args): - scripts, home, _, env = setup - profile = Path(env["CODEX_HOME"]) / "sy.config.toml" - plist = home / "Library" / "LaunchAgents" / "com.nvidia.switchyard.server.plist" - for path in (profile, plist): - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("existing file\n") - before = { - path.relative_to(home): path.read_bytes() for path in home.rglob("*") if path.is_file() - } - command_log = home.parent / "commands.log" - env["SY_TEST_COMMAND_LOG"] = str(command_log) - for stub in Path(env["PATH"].split(":")[0]).iterdir(): - stub.write_text( - stub.read_text().replace( - "set -eu\n", 'set -eu\nprintf "%s\\n" "$0" >> "$SY_TEST_COMMAND_LOG"\n', 1 - ) - ) - - result = subprocess.run( - ["bash", str(scripts / script), *args], - env=env, - capture_output=True, - text=True, - timeout=10, - ) - - assert result.returncode == 2 - assert "Usage:" in result.stderr - assert result.stdout == "" - assert not command_log.exists() - after = { - path.relative_to(home): path.read_bytes() for path in home.rglob("*") if path.is_file() - } - assert after == before