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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Cost savings reporting** — an optional `[pricing]` table in the server
TOML enables `GET /v1/savings` and a live `GET /dashboard` page comparing
routed spend against a baseline model. Purely additive: without pricing
the endpoints are not registered and behavior is unchanged.

### Removed

- **Deprecated Python server stack** — `switchyard serve`, YAML route bundles,
Expand Down
140 changes: 138 additions & 2 deletions crates/switchyard-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ use switchyard_llm_client::{
};
use switchyard_protocol::RoutedLlmClient;

use crate::{CountTokensTarget, ModelCapabilities, ServerError, ServerResult, ServerState};
use crate::{
CountTokensTarget, ModelCapabilities, ModelPrice, SavingsConfig, ServerError, ServerResult,
ServerState,
};

const SUPPORTED_SCHEMA_VERSION: u32 = 1;
const MAX_CONFIGURED_RETRIES: u32 = 10;
Expand Down Expand Up @@ -55,6 +58,10 @@ struct ServerConfig {
llm_clients: BTreeMap<String, LlmClientConfig>,
targets: BTreeMap<String, TargetConfig>,
routes: BTreeMap<String, RouteConfig>,
#[serde(default)]
pricing: BTreeMap<String, PricingConfig>,
#[serde(default)]
savings: Option<SavingsSectionConfig>,
}

impl ServerConfig {
Expand Down Expand Up @@ -109,7 +116,62 @@ impl ServerConfig {
count_tokens_target,
));
}
ServerState::new_with_capabilities(routes)
let state = ServerState::new_with_capabilities(routes)?;
self.apply_savings(state)
}

/// Enables the savings endpoint when a `[pricing]` table is present.
///
/// Stats are keyed by the model id the routed call selected, so
/// `[pricing]` keys are model ids (`target.id`), not TOML target names.
/// Purely additive: no `[pricing]` table means no savings endpoints and
/// no behavior change.
fn apply_savings(&self, state: ServerState) -> ServerResult<ServerState> {
if self.pricing.is_empty() {
if self.savings.is_some() {
return Err(ServerError::new(
"[savings] requires a [pricing] table with at least one model",
));
}
return Ok(state);
}
// Reject unusable rates at startup: a NaN or infinite rate would make
// the savings snapshot unserializable, and a negative rate produces
// negative spend. Matches the build-time validation of other numeric
// config in this file.
let mut pricing: BTreeMap<String, ModelPrice> = BTreeMap::new();
for (model, config) in &self.pricing {
let price = config.into_model_price();
for (field, rate) in [
("input", price.input),
("output", price.output),
("cached", price.cached),
("cache_write", price.cache_write),
] {
if !rate.is_finite() || rate < 0.0 {
return Err(ServerError::new(format!(
"[pricing.\"{model}\"] {field} must be a finite, non-negative rate"
)));
}
}
pricing.insert(model.clone(), price);
}
let baseline = match self
.savings
.as_ref()
.and_then(|s| s.baseline_model.as_ref())
{
Some(model) => {
if !pricing.contains_key(model) {
return Err(ServerError::new(format!(
"savings baseline_model {model} has no [pricing.\"{model}\"] entry"
)));
}
Some(model.clone())
}
None => None,
};
Ok(state.with_savings(SavingsConfig::new(pricing, baseline)))
}
Comment thread
michaelneale marked this conversation as resolved.

fn build_clients(&self) -> ServerResult<BTreeMap<String, Arc<TranslatingLlmClient>>> {
Expand Down Expand Up @@ -255,6 +317,42 @@ struct TargetConfig {
extra_body: BTreeMap<String, Value>,
}

/// Per-model pricing in USD per 1 million tokens, keyed by model id.
///
/// `cached` defaults to 10% of the base input rate (the common provider
/// discount); `cache_write` defaults to the base input rate, matching
/// providers with no cache-write premium.
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PricingConfig {
input: f64,
output: f64,
#[serde(default)]
cached: Option<f64>,
#[serde(default)]
cache_write: Option<f64>,
}

impl PricingConfig {
fn into_model_price(self) -> ModelPrice {
ModelPrice {
input: self.input,
output: self.output,
cached: self.cached.unwrap_or(self.input * 0.1),
cache_write: self.cache_write.unwrap_or(self.input),
}
}
}

/// Optional `[savings]` section selecting the baseline comparison model.
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct SavingsSectionConfig {
/// Model id (a `[pricing]` key) to price the baseline against.
/// Defaults to the most expensive priced model.
baseline_model: Option<String>,
}

#[derive(Clone, Copy, Debug, Deserialize)]
enum ClientFormat {
#[serde(rename = "openai_chat")]
Expand Down Expand Up @@ -1181,6 +1279,44 @@ target = "weak"
);
}

#[test]
fn a_pricing_table_enables_savings() -> ServerResult<()> {
// Pricing is purely additive: without it the savings endpoints are
// not registered and behavior is unchanged.
let state = server_state_from_toml(VALID_CONFIG)?;
assert!(state.savings.is_none());

let priced =
format!("{VALID_CONFIG}\n[pricing.\"weak/model\"]\ninput = 1.0\noutput = 5.0\n");
let state = server_state_from_toml(&priced)?;
assert!(state.savings.is_some());
Ok(())
}

#[test]
fn pricing_rates_must_be_finite_and_non_negative() {
let negative =
format!("{VALID_CONFIG}\n[pricing.\"weak/model\"]\ninput = -1.0\noutput = 5.0\n");
assert!(error_message(&negative).contains("finite, non-negative"));

let non_finite =
format!("{VALID_CONFIG}\n[pricing.\"weak/model\"]\ninput = inf\noutput = 5.0\n");
assert!(error_message(&non_finite).contains("finite, non-negative"));
}

#[test]
fn savings_requires_pricing_and_a_priced_baseline() {
// A [savings] section without any priced model is a config mistake.
let orphan = format!("{VALID_CONFIG}\n[savings]\nbaseline_model = \"strong/model\"\n");
assert!(error_message(&orphan).contains("requires a [pricing] table"));

// The baseline must itself have a pricing entry.
let unpriced_baseline = format!(
"{VALID_CONFIG}\n[pricing.\"weak/model\"]\ninput = 1.0\noutput = 5.0\n\n[savings]\nbaseline_model = \"strong/model\"\n"
);
assert!(error_message(&unpriced_baseline).contains("has no [pricing"));
}

#[test]
fn rejects_unknown_fields_and_algorithm_types() {
let unknown_field =
Expand Down
34 changes: 34 additions & 0 deletions crates/switchyard-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod metrics;
mod observability;
mod response;
mod routing_log;
mod savings;
mod shutdown;
mod sse;
mod stats;
Expand Down Expand Up @@ -45,6 +46,8 @@ use tracing::{Instrument, Level};
use switchyard_translation::{WireFormat, decode_request};

use crate::response::into_http_response;
use crate::savings::SavingsSnapshot;
pub use crate::savings::{ModelPrice, SavingsConfig};
use crate::stats::{StatsAccumulator, StatsSnapshot, prefix_probe, tracking_enabled_from_env};

pub use observability::{flush_observability, initialize_observability};
Expand Down Expand Up @@ -134,6 +137,7 @@ pub struct ServerState {
metrics: prometheus::Registry,
stats: StatsAccumulator,
routing_log: Option<SharedRoutingLog>,
savings: Option<Arc<SavingsConfig>>,
track_cache_eligibility: bool,
}

Expand Down Expand Up @@ -228,6 +232,7 @@ impl ServerState {
metrics,
stats,
routing_log: None,
savings: None,
track_cache_eligibility: tracking_enabled_from_env(),
})
}
Expand All @@ -238,6 +243,12 @@ impl ServerState {
Ok(self)
}

/// Enables the cost-savings endpoint and live dashboard.
pub fn with_savings(mut self, config: SavingsConfig) -> Self {
self.savings = Some(Arc::new(config));
self
}

/// Returns the route model IDs served by the configured algorithms.
pub fn models(&self) -> impl Iterator<Item = &str> {
self.routes.keys().map(String::as_str)
Expand Down Expand Up @@ -471,6 +482,11 @@ pub fn build_switchyard_router(state: ServerState) -> Router {
if state.routing_log.is_some() {
router = router.route("/v1/routing/session-stats", get(get_session_stats));
}
if state.savings.is_some() {
router = router
.route("/v1/savings", get(get_savings))
.route("/dashboard", get(savings_dashboard));
}
router
.fallback(not_found)
.layer(DefaultBodyLimit::max(DEFAULT_MAX_REQUEST_BODY_BYTES))
Expand Down Expand Up @@ -1023,6 +1039,24 @@ async fn get_stats(State(state): State<ServerState>) -> Json<StatsSnapshot> {
Json(state.stats.snapshot())
}

async fn get_savings(State(state): State<ServerState>) -> Response {
let Some(savings) = &state.savings else {
// Unreachable: the route is only registered when savings is configured.
return not_found().await;
};
let snapshot: SavingsSnapshot = savings.compute(&state.stats.snapshot());
(StatusCode::OK, Json(snapshot)).into_response()
}

/// Serves the self-contained live savings dashboard page.
async fn savings_dashboard() -> Response {
(
[(CONTENT_TYPE, "text/html; charset=utf-8")],
include_str!("savings_dashboard.html"),
)
.into_response()
}

async fn reset_stats(State(state): State<ServerState>) -> Json<Value> {
state.stats.reset();
Json(json!({"status": "reset"}))
Expand Down
Loading