diff --git a/CHANGELOG.md b/CHANGELOG.md index de81bbc1d..be8892862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index f7be8c646..c3702ca42 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -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; @@ -55,6 +58,10 @@ struct ServerConfig { llm_clients: BTreeMap, targets: BTreeMap, routes: BTreeMap, + #[serde(default)] + pricing: BTreeMap, + #[serde(default)] + savings: Option, } impl ServerConfig { @@ -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 { + 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 = 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))) } fn build_clients(&self) -> ServerResult>> { @@ -255,6 +317,42 @@ struct TargetConfig { extra_body: BTreeMap, } +/// 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, + #[serde(default)] + cache_write: Option, +} + +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, +} + #[derive(Clone, Copy, Debug, Deserialize)] enum ClientFormat { #[serde(rename = "openai_chat")] @@ -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 = diff --git a/crates/switchyard-server/src/lib.rs b/crates/switchyard-server/src/lib.rs index 7ad32d81b..47ad11fab 100644 --- a/crates/switchyard-server/src/lib.rs +++ b/crates/switchyard-server/src/lib.rs @@ -8,6 +8,7 @@ mod metrics; mod observability; mod response; mod routing_log; +mod savings; mod shutdown; mod sse; mod stats; @@ -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}; @@ -134,6 +137,7 @@ pub struct ServerState { metrics: prometheus::Registry, stats: StatsAccumulator, routing_log: Option, + savings: Option>, track_cache_eligibility: bool, } @@ -228,6 +232,7 @@ impl ServerState { metrics, stats, routing_log: None, + savings: None, track_cache_eligibility: tracking_enabled_from_env(), }) } @@ -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 { self.routes.keys().map(String::as_str) @@ -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)) @@ -1023,6 +1039,24 @@ async fn get_stats(State(state): State) -> Json { Json(state.stats.snapshot()) } +async fn get_savings(State(state): State) -> 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) -> Json { state.stats.reset(); Json(json!({"status": "reset"})) diff --git a/crates/switchyard-server/src/savings.rs b/crates/switchyard-server/src/savings.rs new file mode 100644 index 000000000..bde5e3c10 --- /dev/null +++ b/crates/switchyard-server/src/savings.rs @@ -0,0 +1,304 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Cost and savings accounting derived from the stats snapshot. +//! +//! Pricing is configured per model in the deployment TOML (USD per 1M +//! tokens). Savings compare the actual routed spend against a baseline: +//! what the same traffic would have cost if every request had been served +//! by the baseline (typically the most capable / expensive) model. + +use std::collections::BTreeMap; + +use serde::Serialize; + +use crate::stats::StatsSnapshot; + +/// Per-model pricing in USD per 1 million tokens. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ModelPrice { + /// Base input price (fresh prompt tokens). + pub input: f64, + /// Output / completion price. + pub output: f64, + /// Cache-read price (prompt cache hit). + pub cached: f64, + /// Cache-write price (prompt cache creation). + pub cache_write: f64, +} + +/// Savings configuration: a pricing table plus an optional explicit baseline. +#[derive(Clone, Debug, Default)] +pub struct SavingsConfig { + pricing: BTreeMap, + baseline: Option, +} + +impl SavingsConfig { + /// Creates a savings config from per-model prices and an optional + /// baseline model. When `baseline` is `None`, the most expensive + /// priced model (by input + output rate) is used. + pub fn new(pricing: BTreeMap, baseline: Option) -> Self { + Self { pricing, baseline } + } + + fn price_for(&self, model: &str) -> Option { + self.pricing.get(model).copied() + } + + fn baseline_model(&self) -> Option<(&str, ModelPrice)> { + if let Some(name) = &self.baseline { + return self.price_for(name).map(|price| (name.as_str(), price)); + } + self.pricing + .iter() + .max_by(|a, b| { + let ka = a.1.input + a.1.output; + let kb = b.1.input + b.1.output; + ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(name, price)| (name.as_str(), *price)) + } + + /// Computes a savings snapshot from the current stats snapshot. + pub(crate) fn compute(&self, stats: &StatsSnapshot) -> SavingsSnapshot { + let baseline = self.baseline_model(); + let mut models = BTreeMap::new(); + let mut actual_cost = 0.0; + let mut baseline_cost = 0.0; + let mut unpriced_models = Vec::new(); + + for (model, m) in &stats.models { + let tokens = TokenBuckets { + prompt: m.prompt_tokens, + completion: m.completion_tokens, + cached: m.cached_tokens, + cache_creation: m.cache_creation_tokens, + }; + let price = self.price_for(model); + if price.is_none() { + unpriced_models.push(model.clone()); + } + let cost = price.map(|p| tokens.cost(p)).unwrap_or(0.0); + let would_be = baseline.map(|(_, p)| tokens.cost(p)).unwrap_or(0.0); + actual_cost += cost; + baseline_cost += would_be; + models.insert( + model.clone(), + ModelSavings { + calls: m.calls, + prompt_tokens: m.prompt_tokens, + completion_tokens: m.completion_tokens, + cached_tokens: m.cached_tokens, + cost: round6(cost), + baseline_cost: round6(would_be), + priced: price.is_some(), + }, + ); + } + + // Classifier / judge calls are pure routing overhead: they add to the + // actual spend but a baseline deployment would not make them at all. + let mut classifier_cost = 0.0; + for (model, m) in &stats.classifier.models { + let tokens = TokenBuckets { + prompt: m.prompt_tokens, + completion: m.completion_tokens, + cached: m.cached_tokens, + cache_creation: m.cache_creation_tokens, + }; + match self.price_for(model) { + Some(price) => classifier_cost += tokens.cost(price), + // An unpriced judge is under-counted the same way as an + // unpriced serving model; surface it rather than hide it. + None => { + if !unpriced_models.contains(model) { + unpriced_models.push(model.clone()); + } + } + } + } + actual_cost += classifier_cost; + + let saved = baseline_cost - actual_cost; + let saved_pct = if baseline_cost > 0.0 { + (saved / baseline_cost) * 100.0 + } else { + 0.0 + }; + + SavingsSnapshot { + total_requests: stats.total_requests, + actual_cost: round6(actual_cost), + baseline_cost: round6(baseline_cost), + classifier_cost: round6(classifier_cost), + saved: round6(saved), + saved_pct: round2(saved_pct), + baseline_model: baseline.map(|(name, _)| name.to_string()), + models, + unpriced_models, + } + } +} + +/// Token counters priced by [`TokenBuckets::cost`]. +#[derive(Clone, Copy, Debug, Default)] +struct TokenBuckets { + prompt: u64, + completion: u64, + cached: u64, + cache_creation: u64, +} + +impl TokenBuckets { + /// Splits prompt tokens into base / cached / cache-write buckets and + /// prices each, matching the Python `cost_estimator` semantics. + fn cost(&self, price: ModelPrice) -> f64 { + let base_input = self + .prompt + .saturating_sub(self.cached) + .saturating_sub(self.cache_creation); + (base_input as f64 / 1e6) * price.input + + (self.cached as f64 / 1e6) * price.cached + + (self.cache_creation as f64 / 1e6) * price.cache_write + + (self.completion as f64 / 1e6) * price.output + } +} + +/// Serialized savings response returned by `GET /v1/savings`. +#[derive(Clone, Debug, Serialize)] +pub(crate) struct SavingsSnapshot { + pub total_requests: u64, + /// USD actually spent (routed calls + classifier overhead). + pub actual_cost: f64, + /// USD the same routed traffic would have cost on the baseline model. + pub baseline_cost: f64, + /// USD spent on classifier / judge routing calls. + pub classifier_cost: f64, + /// `baseline_cost - actual_cost`. + pub saved: f64, + /// Percentage of baseline cost saved. + pub saved_pct: f64, + /// Model the baseline comparison is priced against. + pub baseline_model: Option, + pub models: BTreeMap, + /// Models that served traffic but have no configured price (costed at 0). + pub unpriced_models: Vec, +} + +/// Per-model cost breakdown inside [`SavingsSnapshot`]. +#[derive(Clone, Debug, Serialize)] +pub(crate) struct ModelSavings { + pub calls: u64, + pub prompt_tokens: u64, + pub completion_tokens: u64, + pub cached_tokens: u64, + pub cost: f64, + pub baseline_cost: f64, + pub priced: bool, +} + +fn round6(v: f64) -> f64 { + (v * 1e6).round() / 1e6 +} + +fn round2(v: f64) -> f64 { + (v * 1e2).round() / 1e2 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stats::StatsAccumulator; + use crate::stats::TokenUsage; + + fn price(input: f64, output: f64) -> ModelPrice { + ModelPrice { + input, + output, + cached: input * 0.1, + cache_write: input, + } + } + + fn usage(prompt: u64, completion: u64) -> TokenUsage { + TokenUsage { + prompt_tokens: prompt, + completion_tokens: completion, + ..TokenUsage::default() + } + } + + #[test] + fn savings_compare_cheap_traffic_against_expensive_baseline() { + let stats = StatsAccumulator::default(); + stats.record_success("cheap", 10.0); + stats.record_usage("cheap", usage(1_000_000, 1_000_000), 10.0); + + let mut pricing = BTreeMap::new(); + pricing.insert("cheap".to_string(), price(0.1, 0.4)); + pricing.insert("expensive".to_string(), price(3.0, 15.0)); + let config = SavingsConfig::new(pricing, None); + + let snapshot = config.compute(&stats.snapshot()); + assert_eq!(snapshot.baseline_model.as_deref(), Some("expensive")); + assert!((snapshot.actual_cost - 0.5).abs() < 1e-9); + assert!((snapshot.baseline_cost - 18.0).abs() < 1e-9); + assert!((snapshot.saved - 17.5).abs() < 1e-9); + assert!((snapshot.saved_pct - 97.22).abs() < 0.01); + } + + #[test] + fn explicit_baseline_wins_over_most_expensive() { + let stats = StatsAccumulator::default(); + stats.record_success("cheap", 1.0); + stats.record_usage("cheap", usage(1_000_000, 0), 1.0); + + let mut pricing = BTreeMap::new(); + pricing.insert("cheap".to_string(), price(0.1, 0.4)); + pricing.insert("mid".to_string(), price(1.0, 2.0)); + pricing.insert("expensive".to_string(), price(3.0, 15.0)); + let config = SavingsConfig::new(pricing, Some("mid".to_string())); + + let snapshot = config.compute(&stats.snapshot()); + assert_eq!(snapshot.baseline_model.as_deref(), Some("mid")); + assert!((snapshot.baseline_cost - 1.0).abs() < 1e-9); + } + + #[test] + fn unpriced_models_are_reported_not_priced() { + let stats = StatsAccumulator::default(); + stats.record_success("mystery", 1.0); + stats.record_usage("mystery", usage(500_000, 0), 1.0); + + let mut pricing = BTreeMap::new(); + pricing.insert("expensive".to_string(), price(3.0, 15.0)); + let config = SavingsConfig::new(pricing, None); + + let snapshot = config.compute(&stats.snapshot()); + assert_eq!(snapshot.unpriced_models, vec!["mystery".to_string()]); + assert!((snapshot.actual_cost - 0.0).abs() < 1e-9); + // Baseline still counts what that traffic would have cost. + assert!((snapshot.baseline_cost - 1.5).abs() < 1e-9); + } + + #[test] + fn classifier_calls_count_as_overhead_cost_only() { + let stats = StatsAccumulator::default(); + stats.record_success("cheap", 1.0); + stats.record_usage("cheap", usage(1_000_000, 0), 1.0); + stats.record_classifier_success("cheap", Some(usage(1_000_000, 0)), 1.0); + + let mut pricing = BTreeMap::new(); + pricing.insert("cheap".to_string(), price(1.0, 1.0)); + pricing.insert("expensive".to_string(), price(2.0, 2.0)); + let config = SavingsConfig::new(pricing, None); + + let snapshot = config.compute(&stats.snapshot()); + // 1.0 routed + 1.0 classifier overhead. + assert!((snapshot.actual_cost - 2.0).abs() < 1e-9); + assert!((snapshot.classifier_cost - 1.0).abs() < 1e-9); + // Baseline only reprices the routed traffic. + assert!((snapshot.baseline_cost - 2.0).abs() < 1e-9); + } +} diff --git a/crates/switchyard-server/src/savings_dashboard.html b/crates/switchyard-server/src/savings_dashboard.html new file mode 100644 index 000000000..cb3f70868 --- /dev/null +++ b/crates/switchyard-server/src/savings_dashboard.html @@ -0,0 +1,216 @@ + + + + + +Switchyard — Live Savings + + + +

Switchyard — Live Savings

+
+ connecting… +  ·  comparing routed spend against the baseline model for the same traffic. +
+ +
+ +
+
+
Saved vs baseline
+
+
+
+
Dollars saved
+
+
+
+
Actual spend
+
+
+
+
Baseline would cost
+
+
+
+
Requests
+
+
+
+
Routing overhead spend
+
+
+
+ +
+

Routing distribution (by calls)

+
No requests yet.
+
+ +
+

Per-model cost

+
No requests yet.
+
+ +
+
+ + + baseline: +
+
+ +
+ Auto-refreshes every 2s. Streaming and non-streaming requests are both counted. + Tracks cost, not answer quality. +
+ + + + diff --git a/dev-server/config.toml b/dev-server/config.toml index 321789081..bcb49f83a 100644 --- a/dev-server/config.toml +++ b/dev-server/config.toml @@ -77,3 +77,19 @@ base_threshold = 0.5 session_affinity = true message_hash_fallback = true + +# Optional cost savings reporting: pricing (USD per 1M tokens) enables +# GET /v1/savings and the live GET /dashboard page. Purely additive — +# remove this section and the server behaves exactly as before. +# See docs/operations/cost_savings.md. +# +# [pricing."aws/anthropic/bedrock-claude-opus-4-8"] +# input = 15.00 +# output = 75.00 +# +# [pricing."nvidia/zai-org/glm-5.2"] +# input = 0.60 +# output = 2.20 +# +# [savings] +# baseline_model = "aws/anthropic/bedrock-claude-opus-4-8" diff --git a/docs/operations/cost_savings.md b/docs/operations/cost_savings.md new file mode 100644 index 000000000..8db7b0e74 --- /dev/null +++ b/docs/operations/cost_savings.md @@ -0,0 +1,77 @@ +# Cost Savings Reporting + +Switchyard can report, in real time, how much a routed deployment is saving +compared to sending every request to a single baseline model. The feature is +purely additive: it prices the token counters the server already records, and +it never influences routing decisions. + +## Enabling + +Add a `[pricing]` table to the deployment TOML. Keys are **model ids** (the +`id` of a target, not the TOML target name), and rates are USD per 1 million +tokens: + +```toml +[pricing."anthropic/claude-opus-4.7"] +input = 15.00 +output = 75.00 +cached = 1.50 # optional: cache-read rate, defaults to input x 0.1 +cache_write = 18.75 # optional: defaults to input (no cache-write premium) + +[pricing."moonshotai/kimi-k2.7-code"] +input = 0.60 +output = 2.50 + +[savings] +baseline_model = "anthropic/claude-opus-4.7" +``` + +The optional `[savings]` section selects the baseline. When omitted, the most +expensive priced model (by combined input and output rate) is used. A +`baseline_model` must have its own `[pricing]` entry, and a `[savings]` +section without any `[pricing]` table is rejected at startup. + +When no `[pricing]` table is present, the endpoints below are not registered +and the server behaves exactly as before. + +## Endpoints + +| Endpoint | Purpose | +|---|---| +| `GET /v1/savings` | JSON snapshot of actual spend, baseline spend, and savings | +| `GET /dashboard` | Self-contained live HTML dashboard (auto-refreshes) | + +`GET /v1/savings` prices every model that served completions and compares it +against serving the same token traffic with the baseline model: + +```json +{ + "total_requests": 11, + "actual_cost": 0.2864, + "baseline_cost": 0.4722, + "classifier_cost": 0.0026, + "saved": 0.1858, + "saved_pct": 39.35, + "baseline_model": "anthropic/claude-opus-4.7", + "models": { "...": { "calls": 7, "cost": 0.1256, "baseline_cost": 0.3141 } }, + "unpriced_models": [] +} +``` + +Classifier and judge traffic is priced separately as `classifier_cost` and +deducted from the savings, so routing overhead is charged against the result +rather than hidden. Models that served traffic without a pricing entry are +costed at zero and listed in `unpriced_models` so under-counting is visible. + +Counters reset together with the existing stats via `POST /v1/stats/reset`. + +## Semantics + +- Prompt tokens are split into base input, cache reads, and cache writes, and + each bucket is priced separately, matching the cost model of the + `switchyard.cli.launchers` cost estimator. +- The baseline cost re-prices each model's token traffic at the baseline + model's rates. It answers "what would this traffic have cost on the + baseline model", not "what would the baseline model have generated". +- Savings measure cost only. Whether the cheaper model's answers were good + enough is a quality question the report cannot answer. diff --git a/mkdocs.yml b/mkdocs.yml index d081dab58..74ba5885d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - Escalation-Router Routing: routing_algorithms/escalation_router_routing.md - Operations: - Context-Window Handling: operations/context_window.md + - Cost Savings Reporting: operations/cost_savings.md - Reference: - CLI Reference: cli_reference.md - TOML Schema: reference/toml_schema.md