Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
113 changes: 111 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,46 @@ 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);
}
let pricing: BTreeMap<String, ModelPrice> = self
.pricing
.iter()
.map(|(model, config)| (model.clone(), config.into_model_price()))
.collect();
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 +301,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 +1263,33 @@ 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 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