diff --git a/Cargo.lock b/Cargo.lock index 19bf434ba5..8773c11b35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3664,6 +3664,7 @@ dependencies = [ "self-replace", "serde", "serde_json", + "switchyard-costs", "switchyard-runner", "tempfile", "toml 0.9.12+spec-1.1.0", diff --git a/crates/switchyard-costs/prices.toml b/crates/switchyard-costs/prices.toml new file mode 100644 index 0000000000..c2e3ea5d65 --- /dev/null +++ b/crates/switchyard-costs/prices.toml @@ -0,0 +1,247 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Rates are USD per million text tokens. Quote IDs so dots remain part of the ID. +# These defaults stay fixed until Switchyard is updated; an override replaces this table. +# Estimates exclude service tiers, long-context premiums, tools, and time-based cache storage. +# OpenAI cache-write rates cover 30-minute retention, not 12-hour retention. + +# OpenAI's standard short-context rates were checked on 2026-10-21. +# https://developers.openai.com/api/docs/pricing +# GPT-5.6 and newer use the default 30-minute cache-write rate. +# Older models have no extra write fee, so separately counted writes use the input rate. + +["gpt-6-astra"] +input = 10 +output = 50 +cache_read = 1 +cache_write = 12.5 + +["gpt-6.1-sol"] +input = 2 +output = 10 +cache_read = 0.1 +cache_write = 2.5 + +["gpt-6-sol"] +input = 2 +output = 10 +cache_read = 0.2 +cache_write = 2.5 + +["gpt-6-luna"] +input = 0.1 +output = 0.5 +cache_read = 0.01 +cache_write = 0.125 + +["gpt-5.6-sol"] +input = 4 +output = 20 +cache_read = 0.4 +cache_write = 5 + +["gpt-5.6-luna"] +input = 0.2 +output = 1.2 +cache_read = 0.02 +cache_write = 0.25 + +["gpt-5.6-terra"] +input = 2 +output = 12 +cache_read = 0.2 +cache_write = 2.5 + +["gpt-5.5"] +input = 5 +output = 30 +cache_read = 0.5 +cache_write = 5 + +["gpt-5.4-mini"] +input = 0.75 +output = 4.5 +cache_read = 0.075 +cache_write = 0.75 + +["gpt-5.4-nano"] +input = 0.2 +output = 1.25 +cache_read = 0.02 +cache_write = 0.2 + +["gpt-5.2"] +input = 1.75 +output = 14 +cache_read = 0.175 +cache_write = 1.75 + +["gpt-4.1"] +input = 2 +output = 8 +cache_read = 0.5 +cache_write = 2 + +["gpt-4.1-mini"] +input = 0.4 +output = 1.6 +cache_read = 0.1 +cache_write = 0.4 + +["o3"] +input = 2 +output = 8 +cache_read = 0.5 +cache_write = 2 + +["o4-mini"] +input = 1.1 +output = 4.4 +cache_read = 0.275 +cache_write = 1.1 + +# codex-auto-review uses the gpt-6-sol rates for cost and baseline estimates. + +["codex-auto-review"] +input = 2 +output = 10 +cache_read = 0.2 +cache_write = 2.5 + +# These exact IDs occur in the shipped configs. They use OpenAI list-rate estimates; +# marketplace and cloud charges may differ. A reported charge takes precedence. + +["openai/gpt-5.5"] +input = 5 +output = 30 +cache_read = 0.5 +cache_write = 5 + +["openai/gpt-5.2"] +input = 1.75 +output = 14 +cache_read = 0.175 +cache_write = 1.75 + +["openai/gpt-5.6-sol"] +input = 4 +output = 20 +cache_read = 0.4 +cache_write = 5 + +["openai/gpt-5.6-luna"] +input = 0.2 +output = 1.2 +cache_read = 0.02 +cache_write = 0.25 + +["openai/gpt-5.6-terra"] +input = 2 +output = 12 +cache_read = 0.2 +cache_write = 2.5 + +["azure/openai/gpt-5.5"] +input = 5 +output = 30 +cache_read = 0.5 +cache_write = 5 + +["openrouter/openai/gpt-5.6-sol"] +input = 4 +output = 20 +cache_read = 0.4 +cache_write = 5 + +["openrouter/openai/gpt-5.6-terra"] +input = 2 +output = 12 +cache_read = 0.2 +cache_write = 2.5 + +# Anthropic's standard token rates were checked on 2026-10-21. +# https://platform.claude.com/docs/en/about-claude/pricing +# The cache_write field uses the five-minute rate. +# The cache_write_1h field uses the one-hour rate. + +["claude-sonnet-5"] +input = 2 +output = 10 +cache_read = 0.2 +cache_write = 2.5 +cache_write_1h = 4 + +["claude-sonnet-5-5"] +input = 2 +output = 10 +cache_read = 0.1 +cache_write = 2.5 +cache_write_1h = 4 + +["claude-opus-5-5"] +input = 4 +output = 20 +cache_read = 0.2 +cache_write = 5 +cache_write_1h = 8 + +["claude-opus-4-7"] +input = 5 +output = 25 +cache_read = 0.5 +cache_write = 6.25 +cache_write_1h = 10 + +["claude-opus-4-8"] +input = 5 +output = 25 +cache_read = 0.5 +cache_write = 6.25 +cache_write_1h = 10 + +["claude-haiku-4-5"] +input = 1 +output = 5 +cache_read = 0.1 +cache_write = 1.25 +cache_write_1h = 2 + +["claude-haiku-4-5-20251001"] +input = 1 +output = 5 +cache_read = 0.1 +cache_write = 1.25 +cache_write_1h = 2 + +# These exact IDs and token rates were checked against OpenRouter on 2026-10-21. +# https://openrouter.ai/api/v1/models + +["anthropic/claude-sonnet-5"] +input = 2 +output = 10 +cache_read = 0.2 +cache_write = 2.5 +cache_write_1h = 4 + +["anthropic/claude-opus-4.7"] +input = 5 +output = 25 +cache_read = 0.5 +cache_write = 6.25 +cache_write_1h = 10 + +["anthropic/claude-opus-4.8"] +input = 5 +output = 25 +cache_read = 0.5 +cache_write = 6.25 +cache_write_1h = 10 + +# This gateway ID uses Anthropic list rates as an estimate. Gateway charges may differ. +["azure/anthropic/claude-haiku-4-5"] +input = 1 +output = 5 +cache_read = 0.1 +cache_write = 1.25 +cache_write_1h = 2 diff --git a/crates/switchyard-costs/src/lib.rs b/crates/switchyard-costs/src/lib.rs index a0001ea194..8cb739fa98 100644 --- a/crates/switchyard-costs/src/lib.rs +++ b/crates/switchyard-costs/src/lib.rs @@ -73,18 +73,44 @@ pub struct RecordedCost { } impl Pricing { - /// This method loads and validates a JSON pricing file. + /// This method loads the rates shipped with this version of Switchyard. + pub fn bundled() -> Result { + Self::parse_toml(include_str!("../prices.toml")) + } + + /// This method loads TOML from `.toml` files and JSON from other filenames. + /// The extension check ignores ASCII case. pub fn load(path: &Path) -> Result { let bytes = std::fs::read(path) .map_err(|error| format!("read pricing file {}: {error}", path.display()))?; - Self::parse(&bytes) - .map_err(|error| format!("invalid pricing file {}: {error}", path.display())) + let pricing = if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("toml")) + { + std::str::from_utf8(&bytes) + .map_err(|error| error.to_string()) + .and_then(Self::parse_toml) + } else { + Self::parse(&bytes) + }; + pricing.map_err(|error| format!("invalid pricing file {}: {error}", path.display())) } /// This function reads a pricing file's JSON and checks every model ID and rate. pub fn parse(json: &[u8]) -> Result { let pricing: Self = serde_json::from_slice(json).map_err(|error| error.to_string())?; - for (model, rate) in &pricing.0 { + pricing.validate() + } + + /// TOML rates use the same typed fields and validation as JSON rates. + fn parse_toml(text: &str) -> Result { + let pricing: Self = toml::from_str(text).map_err(|error| error.to_string())?; + pricing.validate() + } + + /// Both parsers use this check so model IDs and rate limits agree across formats. + fn validate(self) -> Result { + for (model, rate) in &self.0 { if model.trim().is_empty() || model.trim() != model || [ @@ -103,7 +129,7 @@ impl Pricing { )); } } - Ok(pricing) + Ok(self) } /// Provider charges take precedence because token rates may omit provider surcharges. diff --git a/crates/switchyard-desktop/Cargo.toml b/crates/switchyard-desktop/Cargo.toml index 1c11321a26..53ab0c50e2 100644 --- a/crates/switchyard-desktop/Cargo.toml +++ b/crates/switchyard-desktop/Cargo.toml @@ -15,6 +15,7 @@ publish = false [dependencies] switchyard-runner.workspace = true +switchyard-costs.workspace = true serde.workspace = true serde_json.workspace = true tempfile = "3" diff --git a/crates/switchyard-desktop/prices.toml b/crates/switchyard-desktop/prices.toml deleted file mode 100644 index 744cac30ca..0000000000 --- a/crates/switchyard-desktop/prices.toml +++ /dev/null @@ -1,48 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# The baseline model prices caller-facing calls as if routing had not changed models. -# Savings subtract actual calls, including classifier calls, from that estimate. -# The default route sends each call to gpt-6-sol or gpt-6-luna, so the baseline is gpt-6-sol. -baseline_model = "gpt-6-sol" - -# OpenAI standard API prices per million tokens, for prompts up to 272K input tokens. -[prices."gpt-6-sol"] -input_per_mtok = 2.0 -cached_input_per_mtok = 0.2 -output_per_mtok = 10.0 - -[prices."gpt-6-luna"] -input_per_mtok = 0.1 -cached_input_per_mtok = 0.01 -output_per_mtok = 0.5 - -[prices."gpt-6.1-sol"] -input_per_mtok = 2.0 -cached_input_per_mtok = 0.1 -output_per_mtok = 10.0 - -[prices."gpt-5.6-sol"] -input_per_mtok = 1.25 -cached_input_per_mtok = 0.125 -output_per_mtok = 10.0 - -[prices."gpt-5.6-luna"] -input_per_mtok = 0.25 -cached_input_per_mtok = 0.025 -output_per_mtok = 2.0 - -[prices."gpt-5.6-terra"] -input_per_mtok = 0.05 -cached_input_per_mtok = 0.005 -output_per_mtok = 0.4 - -# With a ChatGPT login, Codex sends "Approve for me" reviews to -# codex-auto-review even without Switchyard, so this price copies the -# baseline_model rates. Reviews then add the same amount to the actual cost and -# the baseline. The dollars saved stay the same, but the percentage drops a -# little. Update this price if you change baseline_model. -[prices."codex-auto-review"] -input_per_mtok = 2.0 -cached_input_per_mtok = 0.2 -output_per_mtok = 10.0 diff --git a/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs b/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs index 92e4df6781..42ed3b7a56 100644 --- a/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs +++ b/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs @@ -150,22 +150,11 @@ fn serve(file: &Path) -> Result<(), String> { use std::os::unix::process::CommandExt; let s = Settings::load(file)?; let config = s.sy_home.join("composite.toml"); - let mut server = std::process::Command::new(s.binary("switchyard-server")); + let mut server = s.server_command(); #[cfg(target_os = "linux")] credentials::wait_for_keyring()?; credentials::configure(&mut server, &config)?; - let error = server - .arg("--config") - .arg(config) - .args([ - "--host", - "127.0.0.1", - "--port", - &s.port.to_string(), - "--routing-log-file", - ]) - .arg(s.sy_home.join("routing.jsonl")) - .exec(); + let error = server.exec(); Err(format!("Could not start the server: {error}")) } diff --git a/crates/switchyard-desktop/src/install.rs b/crates/switchyard-desktop/src/install.rs index f8d4988e5b..e173f8d0d6 100644 --- a/crates/switchyard-desktop/src/install.rs +++ b/crates/switchyard-desktop/src/install.rs @@ -14,6 +14,8 @@ use std::{ time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; +use switchyard_costs::Pricing; + pub mod credentials; // Linux and any other system without launchd or Task Scheduler use systemd; execute rejects // unsupported systems before they change anything. @@ -202,6 +204,29 @@ impl Settings { pub fn desktop_settings(&self) -> PathBuf { self.sy_home.join("desktop.toml") } + /// This optional file overrides bundled rates for the server and app baseline. + pub fn prices_file(&self) -> PathBuf { + self.sy_home.join("prices.json") + } + /// This method builds the installed server command with any existing price override. + /// The caller configures and starts the process. + pub fn server_command(&self) -> Command { + let mut command = Command::new(self.binary("switchyard-server")); + command + .arg("--config") + .arg(self.sy_home.join("composite.toml")); + command.args(["--host", "127.0.0.1"]); + command.arg("--port").arg(self.port.to_string()); + command + .arg("--routing-log-file") + .arg(self.sy_home.join("routing.jsonl")); + + let prices = self.prices_file(); + if prices.exists() { + command.arg("--pricing-file").arg(prices); + } + command + } fn profile_file(&self) -> PathBuf { self.codex_home .join(format!("{}.config.toml", self.profile)) @@ -476,6 +501,12 @@ fn installation_plan(s: &Settings, action: &str) -> String { }; rows.push(format!("{verb} {label}: {}", path.display())); } + if s.prices_file().exists() { + rows.push(format!( + "Preserve model price override: {}", + s.prices_file().display() + )); + } rows.push(format!( "{profile_action} Codex profile: {}", profile.display() @@ -708,6 +739,10 @@ fn prepare(s: &Settings, preserve_profile: bool) -> Result { #[cfg(not(windows))] platform::preflight()?; let desktop_settings = reconcile_desktop_port(s)?; + let prices = s.prices_file(); + if present(&prices)? { + Pricing::load(&prices).map_err(|e| format!("{e}. Fix this file before installing."))?; + } build(s)?; let missing_keys = check_server_config(s, !preserve_profile || !profile.exists())?; Ok(Prepared { @@ -1017,20 +1052,29 @@ fn merge_codex_profile( } fn desktop_config(s: &Settings) -> String { - format!( + let mut config = format!( "server_url = {:?}\n\ routing_log = {}\n\ config_file = {}\n\ launchd_label = {:?}\n\ refresh_seconds = 30\n\ \n\ - {}", + # Savings compare recorded costs with answer tokens priced at baseline_model rates.\n\ + # The app uses bundled rates unless pricing_file is set.\n\ + baseline_model = \"gpt-6-sol\"\n", format!("http://127.0.0.1:{}", s.port), toml_string(&s.sy_home.join("routing.jsonl").to_string_lossy()), toml_string(&s.sy_home.join("composite.toml").to_string_lossy()), SERVER, - include_str!("../prices.toml") - ) + ); + let prices = s.prices_file(); + if prices.exists() { + config.push_str(&format!( + "pricing_file = {}\n", + toml_string(&prices.to_string_lossy()) + )); + } + config } fn toml_string(value: &str) -> String { toml::Value::String(value.into()).to_string() diff --git a/crates/switchyard-desktop/src/install/windows.rs b/crates/switchyard-desktop/src/install/windows.rs index 37f3677212..d5097cb39d 100644 --- a/crates/switchyard-desktop/src/install/windows.rs +++ b/crates/switchyard-desktop/src/install/windows.rs @@ -384,10 +384,13 @@ pub fn temporary_updater(source: &Path) -> Result { } /// This function waits for its child so Task Scheduler tracks the app or server's lifetime. pub fn launch_managed(s: &Settings, desktop: bool) -> Result<(), String> { - let (program, log) = if desktop { - ("switchyard-desktop", "logs/desktop.log") + let (mut command, log) = if desktop { + ( + Command::new(s.binary("switchyard-desktop")), + "logs/desktop.log", + ) } else { - ("switchyard-server", "logs/server.log") + (s.server_command(), "logs/server.log") }; fs::create_dir_all(s.sy_home.join("logs")).map_err(|e| e.to_string())?; let log = fs::OpenOptions::new() @@ -395,33 +398,23 @@ pub fn launch_managed(s: &Settings, desktop: bool) -> Result<(), String> { .append(true) .open(s.sy_home.join(log)) .map_err(|e| e.to_string())?; - let mut c = Command::new(s.binary(program)); - hide_console(&mut c); - c.env("HOME", &s.home) + hide_console(&mut command); + command + .env("HOME", &s.home) .env("USERPROFILE", &s.home) .env("CODEX_HOME", &s.codex_home) .env("SY_HOME", &s.sy_home) .current_dir(s.bin_dir()); if desktop { - c.arg(s.desktop_settings()); + command.arg(s.desktop_settings()); } else { let config = s.sy_home.join("composite.toml"); - c.arg("--config") - .arg(&config) - .args([ - "--host", - "127.0.0.1", - "--port", - &s.port.to_string(), - "--routing-log-file", - ]) - .arg(s.sy_home.join("routing.jsonl")); // The helper has no console, so a missing key is reported in the server log. - credentials::configure(&mut c, &config).inspect_err(|error| { + credentials::configure(&mut command, &config).inspect_err(|error| { let _ = writeln!(&log, "switchyard-desktop-install: {error}"); })?; } - let status = c + let status = command .stdin(Stdio::null()) .stderr(log.try_clone().map_err(|e| e.to_string())?) .stdout(log) diff --git a/crates/switchyard-menubar/README.md b/crates/switchyard-menubar/README.md index 8525a5878e..8c31e1069c 100644 --- a/crates/switchyard-menubar/README.md +++ b/crates/switchyard-menubar/README.md @@ -36,20 +36,36 @@ routing_log = "~/.switchyard/routing.jsonl" baseline_model = "gpt-5.6-sol" refresh_seconds = 30 -pricing_file = "~/.switchyard/prices.json" +# This optional file replaces the bundled price table. +# pricing_file = "~/.switchyard/prices.toml" ``` -The server's `--pricing-file` and the menubar's `pricing_file` use the same JSON file. +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 standard OpenAI and Claude text-token rates. Estimates exclude long-context +and service-tier adjustments, OpenAI's 12-hour cache-write pricing, tool fees, and +time-based cache storage. Entries with provider prefixes use list-rate estimates; +billed charges may differ. 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: -```json -{"gpt-5.6-sol":{"input":1.25,"cache_read":0.125,"cache_write":1.25,"output":10}} +```toml +["gpt-5.6-sol"] +input = 4 +cache_read = 0.4 +cache_write = 5 +output = 20 ``` -These example rates preserve the installer's previous assumptions. Replace them with -what you pay. The [pi guide](../../docs/integrations/pi.md#estimate-the-cost) explains +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. +applications after editing them. An unreadable or invalid override file stops startup; +the applications do not use bundled rates instead. ## How savings are computed @@ -66,9 +82,11 @@ or required one-hour cache-write rate keeps the baseline unknown. Missing or inv 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. -Codex's `codex-auto-review` model keeps the installer's baseline rates by default. -Update that entry when changing `baseline_model` if you want those reviews to have -the same estimated cost with and without Switchyard. ChatGPT subscription traffic +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 diff --git a/crates/switchyard-menubar/src/config.rs b/crates/switchyard-menubar/src/config.rs index cebc785bf3..185fca0719 100644 --- a/crates/switchyard-menubar/src/config.rs +++ b/crates/switchyard-menubar/src/config.rs @@ -26,7 +26,7 @@ pub struct Config { pub refresh_seconds: u64, /// Model the traffic is assumed to have used without Switchyard. pub baseline_model: String, - /// The shared JSON file supplies rates for the baseline estimate. + /// 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)] @@ -61,6 +61,8 @@ impl Config { 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); diff --git a/crates/switchyard-menubar/src/summary.rs b/crates/switchyard-menubar/src/summary.rs index 024d42f016..42df978304 100644 --- a/crates/switchyard-menubar/src/summary.rs +++ b/crates/switchyard-menubar/src/summary.rs @@ -52,7 +52,7 @@ pub fn build(status: ServerStatus, usage: &Usage, config: &Config) -> Vec { if estimate(&usage.week, &config.prices, &config.baseline_model).is_none() { rows.push(Row::Separator); rows.push(label( - "Savings need complete recorded costs and baseline rates in prices.json", + "Savings need complete recorded costs, answer usage, and baseline rates", )); } rows diff --git a/crates/switchyard-server/README.md b/crates/switchyard-server/README.md index aa454a4607..2cea4e2f3e 100644 --- a/crates/switchyard-server/README.md +++ b/crates/switchyard-server/README.md @@ -124,21 +124,36 @@ served model. The legacy `proxy_x_session_id` remains a fallback when no normali present. The endpoint returns `404` when the session has no records and is not registered when routing logging is disabled. -The routing log saves each call's reported charge in USD. Add `--pricing-file prices.json` -to estimate costs that the upstream does not report. The JSON maps served model IDs to rates -in USD per million tokens: +The routing log saves each call's reported charge in USD. When the upstream reports no +charge, the server estimates it using [bundled rates](../switchyard-costs/prices.toml). +Bundled rates stay fixed until you install a version with updated rates. +The table includes OpenAI and Claude text-token rates. Model IDs match exactly, +including provider prefixes; an unlisted ID has no estimated cost. +These are standard-rate estimates. They exclude long-context and service-tier +adjustments, OpenAI's 12-hour cache-write pricing, tool fees, and time-based cache +storage. Entries with provider prefixes use list-rate estimates; billed charges may differ. +Use `--pricing-file prices.toml` to replace the entire bundled table with your own rates. +Use a `.toml` file with quoted served model IDs and rates in USD per million tokens: -```json -{"upstream/model":{"input":1,"output":5,"cache_read":0.1,"cache_write":2}} +```toml +["upstream/model"] +input = 1 +output = 5 +cache_read = 0.1 +cache_write = 2 ``` +The `.toml` extension check ignores case. Other filenames are read as JSON, so existing +JSON overrides still work. + Session stats include cost totals. Missing usage or rates leave cost unknown; `known_usd` keeps the priced subtotal and `total_usd` is null when any cost is unknown. Rates load once -at startup, and saved costs do not change when prices change. +at startup, and saved costs do not change when prices change. An unreadable or invalid +`--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; its actual spending comes from recorded costs. +setting when you override the bundled rates; its 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 diff --git a/crates/switchyard-server/src/cli.rs b/crates/switchyard-server/src/cli.rs index 9694457921..d94172fb4e 100644 --- a/crates/switchyard-server/src/cli.rs +++ b/crates/switchyard-server/src/cli.rs @@ -52,7 +52,7 @@ pub(crate) struct ServerArgs { #[arg(long, value_name = "PATH")] routing_log_file: Option, - /// This JSON file maps served model IDs to rates in USD per million tokens. + /// This optional TOML or JSON file replaces the entire bundled table of rates in USD per million tokens. #[arg(long, value_name = "PATH", requires = "routing_log_file")] pricing_file: Option, diff --git a/crates/switchyard-server/src/routing_log.rs b/crates/switchyard-server/src/routing_log.rs index 01dddfb6b3..e62ac72ff1 100644 --- a/crates/switchyard-server/src/routing_log.rs +++ b/crates/switchyard-server/src/routing_log.rs @@ -45,7 +45,7 @@ impl RoutingLog { .map_err(|error| routing_log_error(&path, error))?; Ok(Self { file, - pricing: Pricing::default(), + pricing: Pricing::bundled().map_err(ServerError::new)?, }) } diff --git a/docs/integrations/pi.md b/docs/integrations/pi.md index 1c71f9aa3d..e0f9cc8326 100644 --- a/docs/integrations/pi.md +++ b/docs/integrations/pi.md @@ -155,7 +155,10 @@ second shows `cached_tokens` close to `prompt_tokens`. If the second record show #### Estimate the cost -The server matches each token count in the routing log to a rate in the pricing file: +The server uses bundled rates by default. To use your provider's rates or add missing +models, supply a TOML or JSON file with `--pricing-file`. It replaces the entire bundled table. +An unreadable or invalid file stops startup; the server does not substitute bundled rates. +The server matches each token count in the routing log to a rate: | Tokens in the record | Pricing-file key | |---|---| @@ -168,16 +171,22 @@ The server matches each token count in the routing log to a rate in the pricing Treat a missing `cache_creation_1h_tokens` count as zero. That count is part of `cache_creation_tokens`, so it must not be charged twice. -Write the prices to `prices.json` in USD per million tokens, and key each entry by the +Write the prices to `prices.toml` in USD per million tokens, and key each entry by the record's `model` value. This example uses Anthropic's list prices for Claude Sonnet 5 on 2026-10-02: -```json -{ - "claude-sonnet-5": {"input": 2.00, "cache_read": 0.20, "cache_write": 2.50, "output": 10.00} -} +```toml +["claude-sonnet-5"] +input = 2.00 +cache_read = 0.20 +cache_write = 2.50 +output = 10.00 ``` +Put each quoted model ID in a top-level table, as shown above. Quotes keep dots +within the ID. The `.toml` extension check ignores case; other filenames are read +as JSON, so existing JSON overrides still work. + For your own models, copy the entry for each model and replace the key with its exact `model` value from the routing log. Use the served model ID rather than the routing alias, and replace the example's numbers with your provider's rates. List recorded model IDs with: @@ -195,10 +204,11 @@ the input price instead of 1.25 times; set `cache_write_1h` to `4.00` for that e Include that rate to estimate reported one-hour cache writes; without it, the estimate remains unknown. -Start the server with `--pricing-file prices.json` as well as `--routing-log-file PATH`. +To use your file, start the server with `--pricing-file prices.toml` as well as +`--routing-log-file PATH`. The server records the gateway's reported charge when available, even without rates. -Otherwise it uses the pricing file to estimate the cost. If usage is missing or the model -has no rates, the estimate is unknown. Read each request's cost with: +Otherwise it uses bundled rates or your override file to estimate the cost. If usage +is missing or the model has no rates, the estimate is unknown. Read each request's cost with: ```bash jq -c '{route_id, model, cost}' PATH diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index f3cff8135b..8fb6e2a920 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -55,9 +55,21 @@ step "Writing server config" write_once "$SY_HOME/composite.toml" < "$REPO_ROOT/scripts/config/composite.toml" step "Writing menu bar settings" -# The server and menubar share rates in USD per million tokens. -# Edit prices.json to match what you actually pay. +# 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" < @@ -112,8 +107,7 @@ write_always "$LAUNCH_AGENTS/$SERVER_LABEL.plist" <$SY_PORT --routing-log-file $XML_SY_HOME/routing.jsonl - --pricing-file - $XML_SY_HOME/prices.json + $PRICING_XML RunAtLoad