Skip to content

feat(server): opt-in cost savings endpoint and live dashboard - #378

Open
michaelneale wants to merge 4 commits into
NVIDIA-NeMo:mainfrom
michaelneale:savings-dashboard
Open

feat(server): opt-in cost savings endpoint and live dashboard#378
michaelneale wants to merge 4 commits into
NVIDIA-NeMo:mainfrom
michaelneale:savings-dashboard

Conversation

@michaelneale

@michaelneale michaelneale commented Aug 12, 2026

Copy link
Copy Markdown

What

Adds opt-in cost savings reporting to switchyard-server: an optional [pricing] table in the deployment TOML enables GET /v1/savings (JSON) and GET /dashboard (self-contained live HTML page) that compare actual routed spend against a baseline model — "what would this traffic have cost if every request went to the most capable target".

live savings dashboard

Why

The routing algorithms (llm_classifier, stage_router, escalation) exist largely to spend the capable model only on turns that need it. Today the only way to see what that actually saved is post-hoc analysis of /v1/stats dumps (as the Python cost_estimator in the launchers does). This makes the payoff visible live while a coding agent runs through the proxy.

How

  • Purely additive. No [pricing] table → the endpoints are not registered and behavior is unchanged. Pricing never influences routing decisions.

  • Config:

    [pricing."anthropic/claude-opus-4.7"]
    input = 15.00        # USD per 1M tokens
    output = 75.00
    cached = 1.50        # optional, defaults to input x 0.1
    cache_write = 18.75  # optional, defaults to input
    
    [savings]
    baseline_model = "anthropic/claude-opus-4.7"  # optional; defaults to priciest priced model
  • [pricing] is keyed by model id (target.id), matching how stats are keyed, so one entry covers all targets sharing a model.

  • Pricing semantics (base input / cache read / cache write / output buckets) match switchyard.cli.launchers.cost_estimator.

  • Classifier/judge traffic is priced separately as classifier_cost and deducted from savings, so routing overhead is charged honestly against the result.

  • Models serving traffic without a pricing entry are costed at zero and surfaced in unpriced_models (and as a dashboard warning) so under-counting is visible.

  • The dashboard is a single embedded HTML file with no external dependencies, polling /v1/savings every 2s. Counters reset with the existing POST /v1/stats/reset.

  • Endpoint registration is gated the same way as the existing session-stats route.

Sample /v1/savings output

From a real session (goose coding agent through an llm_classifier route, cheap judge, Sonnet weak / Opus strong, plus some Opus-pinned A/B traffic):

{
  "total_requests": 11,
  "actual_cost": 0.2864,
  "baseline_cost": 0.4722,
  "classifier_cost": 0.0026,
  "saved": 0.1858,
  "saved_pct": 39.35,
  "baseline_model": "claude-opus-5",
  "models": {
    "claude-opus-5":   { "calls": 4, "cost": 0.1581, "baseline_cost": 0.1581 },
    "claude-sonnet-5": { "calls": 7, "cost": 0.1256, "baseline_cost": 0.3141 }
  },
  "unpriced_models": []
}

Testing

  • 6 new unit tests (savings math incl. cache buckets and classifier overhead; config validation: additive gating, [savings] without pricing rejected, unpriced baseline rejected)
  • cargo test -p switchyard-server — 62 tests green; fmt and clippy clean
  • Verified live end to end against Anthropic + OpenAI backends with a coding agent driving mixed traffic

Notes for reviewers

  • Happy to split the dashboard page out if you'd prefer the JSON endpoint only — the endpoint stands alone.
  • Naming, config shape, and where the docs page lives are all easy to change; the docs page is under Operations alongside context-window handling.

Summary by CodeRabbit

  • New Features
    • Added optional cost-savings reporting with configurable per-model pricing and baseline comparisons.
    • Added /v1/savings for savings metrics and /dashboard for a live cost and savings dashboard.
    • Dashboard includes spending, savings, routing, per-model costs, unpriced-model warnings, refresh, and reset controls.
  • Documentation
    • Added setup, configuration, API, dashboard, and pricing guidance to the operations documentation.
  • Removed
    • Removed deprecated Python server components, legacy route bundles, endpoints, chain support, and compatibility bindings.

Adds per-target pricing (USD per 1M tokens) to the server TOML and,
when any target is priced, registers GET /v1/savings (JSON) and
GET /dashboard (self-contained live HTML page). Savings compare actual
routed spend against a baseline model - what the same traffic would
have cost if every request had been served by the most capable target.
Classifier calls are counted as routing overhead against the savings.

Pricing semantics (base input / cache read / cache write / output
buckets) match switchyard.cli.launchers.cost_estimator. Fully opt-in:
no pricing in config means no new routes and no behaviour change.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
…by model id

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
@michaelneale
michaelneale requested a review from a team as a code owner August 12, 2026 03:18
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change adds optional model pricing and savings accounting. Configured servers expose /v1/savings and /dashboard. The dashboard displays live cost data and supports refresh and reset actions. Operations documentation describes configuration and endpoint behavior.

Changes

Savings reporting

Layer / File(s) Summary
Pricing configuration and savings accounting
crates/switchyard-server/src/config.rs, crates/switchyard-server/src/savings.rs
Adds per-model token pricing, baseline selection, savings calculations, rounding, and tests for baseline, unpriced, and classifier traffic.
Server configuration and endpoint wiring
crates/switchyard-server/src/config.rs, crates/switchyard-server/src/lib.rs
Validates savings configuration, stores it in ServerState, and conditionally registers /v1/savings and /dashboard.
Dashboard and operational documentation
crates/switchyard-server/src/savings_dashboard.html, docs/operations/cost_savings.md, mkdocs.yml, CHANGELOG.md
Adds the live dashboard, documents pricing and savings behavior, adds navigation, and records the unreleased feature.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Poem

I’m a rabbit with prices to weigh,
Counting tokens through night and day.
Baselines hop, savings grow,
Unpriced models clearly show.
The dashboard refreshes—hooray!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the opt-in cost savings endpoint and live dashboard added by the pull request.
Docstring Coverage ✅ Passed Docstring coverage is 83.87% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (4)
crates/switchyard-server/src/savings_dashboard.html (2)

130-144: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use fmt for the call count, as renderModels does.

Line 141 interpolates m.calls directly. renderModels passes the same field through fmt(m.calls) on line 153. Align the two so the distribution row groups thousands the same way as the table.

This also removes the only raw interpolation of a server-supplied value in the template. The static analysis inner-outer-html warnings on lines 133-142 and 159-163 are handled: esc already wraps every model name, and the remaining values are numeric. The manual-sanitization hint recommends DOMPurify, which does not apply here. The page is embedded with include_str! and is intentionally dependency-free.

♻️ Proposed consistency change
-        <div class="pct">${m.calls} calls · ${pct.toFixed(1)}%</div>
+        <div class="pct">${fmt(m.calls)} calls · ${pct.toFixed(1)}%</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/src/savings_dashboard.html` around lines 130 - 144,
Update renderDist to format m.calls with the existing fmt helper in the
distribution row, matching renderModels while leaving the percentage calculation
unchanged.

Source: Linters/SAST tools


195-199: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Report a failed reset to the user.

The reset handler ignores the response status. If POST /v1/stats/reset returns an error, the page calls refresh() and shows the unchanged counters with the status still set to "live". The operator gets no signal that the reset failed.

♻️ Proposed change
 $("reset").addEventListener("click", async () => {
-  await fetch("/v1/stats/reset", {method:"POST"});
-  refresh();
+  try {
+    const r = await fetch("/v1/stats/reset", {method:"POST"});
+    if(!r.ok) throw new Error(await r.text());
+  } catch(e) {
+    $("status").textContent = "reset failed";
+    return;
+  }
+  refresh();
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/src/savings_dashboard.html` around lines 195 - 199,
Update the reset click handler near the $("reset") listener to inspect the POST
/v1/stats/reset response before calling refresh(). On a non-success response,
report the reset failure through the page’s existing user-visible status/error
mechanism and avoid presenting the unchanged counters as successfully reset;
retain the refresh flow only for successful resets.
crates/switchyard-server/src/savings.rs (2)

91-102: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider rounding the per-model costs too.

compute applies round6 to the snapshot totals but stores the raw f64 for ModelSavings::cost and ModelSavings::baseline_cost. The JSON response therefore mixes rounded totals with full-precision per-model values, and the per-model column can show long floats to any consumer that does not format them. The bundled dashboard formats with toFixed, so nothing is visibly wrong today.

Apply round6 to both per-model fields for a consistent response contract.

♻️ Proposed consistency change
-                    cost,
-                    baseline_cost: would_be,
+                    cost: round6(cost),
+                    baseline_cost: round6(would_be),

Also applies to: 199-205

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/src/savings.rs` around lines 91 - 102, Apply the
existing round6 helper to both cost and baseline_cost when constructing each
ModelSavings entry in compute, while leaving the raw calculations and priced
flag unchanged.

45-48: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Restrict is_empty unless it is part of the external API. No in-repository code calls SavingsConfig::is_empty; use pub(crate) or remove it if external callers do not need it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/src/savings.rs` around lines 45 - 48, Restrict the
visibility of SavingsConfig::is_empty from public external API access to
pub(crate), or remove the method if it is not required by external callers;
preserve its existing pricing.is_empty behavior if retained.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/switchyard-server/src/config.rs`:
- Around line 129-159: Validate every pricing rate in apply_savings before
converting entries with into_model_price. Reject negative or non-finite f64
values, including NaN and infinities, by returning a ServerError during
configuration loading; only construct SavingsConfig after all rates pass
validation, preserving the existing baseline_model checks.

In `@crates/switchyard-server/src/savings_dashboard.html`:
- Around line 167-202: Update refresh and the polling setup to prevent
concurrent requests: track an in-flight state, return immediately when refresh
is already running or document.hidden is true, and clear the state in all
completion paths. Replace the unconditional setInterval polling with
visibility-aware scheduling that resumes on visibility changes and preserves
manual refresh behavior; do not apply the React-specific setstate-same-var hint.

In `@crates/switchyard-server/src/savings.rs`:
- Around line 105-119: Update the classifier-cost loop in the savings
calculation to append each classifier model lacking a price to the existing
unpriced_models collection, matching the routed-model loop’s behavior. Keep its
cost at zero while ensuring the model is surfaced for reporting and dashboard
warnings.

---

Nitpick comments:
In `@crates/switchyard-server/src/savings_dashboard.html`:
- Around line 130-144: Update renderDist to format m.calls with the existing fmt
helper in the distribution row, matching renderModels while leaving the
percentage calculation unchanged.
- Around line 195-199: Update the reset click handler near the $("reset")
listener to inspect the POST /v1/stats/reset response before calling refresh().
On a non-success response, report the reset failure through the page’s existing
user-visible status/error mechanism and avoid presenting the unchanged counters
as successfully reset; retain the refresh flow only for successful resets.

In `@crates/switchyard-server/src/savings.rs`:
- Around line 91-102: Apply the existing round6 helper to both cost and
baseline_cost when constructing each ModelSavings entry in compute, while
leaving the raw calculations and priced flag unchanged.
- Around line 45-48: Restrict the visibility of SavingsConfig::is_empty from
public external API access to pub(crate), or remove the method if it is not
required by external callers; preserve its existing pricing.is_empty behavior if
retained.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 65f3e994-00e7-43b1-9749-e66f9d453e24

📥 Commits

Reviewing files that changed from the base of the PR and between 2bef154 and f744daf.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • crates/switchyard-server/src/config.rs
  • crates/switchyard-server/src/lib.rs
  • crates/switchyard-server/src/savings.rs
  • crates/switchyard-server/src/savings_dashboard.html
  • docs/operations/cost_savings.md
  • mkdocs.yml

Comment on lines +129 to 159
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)))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate the pricing rates before you build ModelPrice.

apply_savings accepts any f64 from the TOML, including negative, inf, and nan. A nan or inf rate propagates through SavingsConfig::compute into actual_cost, baseline_cost, saved, and saved_pct. serde_json cannot serialize non-finite floats, so GET /v1/savings then fails at response time instead of at startup. A negative rate silently produces negative spend.

Other numeric configuration in this file is validated at build time (base_threshold, max_retries, context_window). Apply the same treatment here.

🛡️ Proposed fix to reject non-finite and negative rates
-        let pricing: BTreeMap<String, ModelPrice> = self
-            .pricing
-            .iter()
-            .map(|(model, config)| (model.clone(), config.into_model_price()))
-            .collect();
+        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 finite and greater than or equal to 0"
+                    )));
+                }
+            }
+            pricing.insert(model.clone(), price);
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested 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)))
}
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 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 finite and greater than or equal to 0"
)));
}
}
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)))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/src/config.rs` around lines 129 - 159, Validate
every pricing rate in apply_savings before converting entries with
into_model_price. Reject negative or non-finite f64 values, including NaN and
infinities, by returning a ServerError during configuration loading; only
construct SavingsConfig after all rates pass validation, preserving the existing
baseline_model checks.

Comment on lines +167 to +202
async function refresh(){
try {
const r = await fetch("/v1/savings");
if(!r.ok) throw new Error(await r.text());
const d = await r.json();
$("saved_pct").textContent = d.saved_pct.toFixed(1) + "%";
$("saved_usd").textContent = money(d.saved);
$("actual").textContent = money(d.actual_cost);
$("baseline").textContent = money(d.baseline_cost);
$("requests").textContent = fmt(d.total_requests);
$("classifier").textContent = money(d.classifier_cost);
$("baseline_model").textContent = d.baseline_model || "—";
renderDist(d.models || {});
renderModels(d.models || {});
const unpriced = d.unpriced_models || [];
if(unpriced.length){
$("unpriced").style.display = "block";
$("unpriced").textContent =
"No price configured for: " + unpriced.join(", ") + " — costed at $0.";
} else {
$("unpriced").style.display = "none";
}
$("status").textContent = "live";
} catch(e) {
$("status").textContent = "fetch failed";
}
}

$("refresh").addEventListener("click", refresh);
$("reset").addEventListener("click", async () => {
await fetch("/v1/stats/reset", {method:"POST"});
refresh();
});

refresh();
setInterval(refresh, 2000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard against overlapping polls.

setInterval(refresh, 2000) starts a new request every two seconds regardless of whether the previous one finished. fetch has no timeout and no AbortController. If the server stalls or the network is slow, requests accumulate and saturate the browser's per-origin connection pool. The interval also keeps polling while the tab is hidden.

Add an in-flight flag, and pause polling when the document is hidden.

The ast-grep setstate-same-var hint on line 201 is a React rule and does not apply to this page.

🛡️ Proposed fix
+let inFlight = false;
 async function refresh(){
+  if(inFlight) return;
+  inFlight = true;
   try {
     const r = await fetch("/v1/savings");
     if(!r.ok) throw new Error(await r.text());
     $("status").textContent = "live";
   } catch(e) {
     $("status").textContent = "fetch failed";
+  } finally {
+    inFlight = false;
   }
 }
 refresh();
-setInterval(refresh, 2000);
+setInterval(() => { if(!document.hidden) refresh(); }, 2000);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function refresh(){
try {
const r = await fetch("/v1/savings");
if(!r.ok) throw new Error(await r.text());
const d = await r.json();
$("saved_pct").textContent = d.saved_pct.toFixed(1) + "%";
$("saved_usd").textContent = money(d.saved);
$("actual").textContent = money(d.actual_cost);
$("baseline").textContent = money(d.baseline_cost);
$("requests").textContent = fmt(d.total_requests);
$("classifier").textContent = money(d.classifier_cost);
$("baseline_model").textContent = d.baseline_model || "—";
renderDist(d.models || {});
renderModels(d.models || {});
const unpriced = d.unpriced_models || [];
if(unpriced.length){
$("unpriced").style.display = "block";
$("unpriced").textContent =
"No price configured for: " + unpriced.join(", ") + " — costed at $0.";
} else {
$("unpriced").style.display = "none";
}
$("status").textContent = "live";
} catch(e) {
$("status").textContent = "fetch failed";
}
}
$("refresh").addEventListener("click", refresh);
$("reset").addEventListener("click", async () => {
await fetch("/v1/stats/reset", {method:"POST"});
refresh();
});
refresh();
setInterval(refresh, 2000);
let inFlight = false;
async function refresh(){
if(inFlight) return;
inFlight = true;
try {
const r = await fetch("/v1/savings");
if(!r.ok) throw new Error(await r.text());
const d = await r.json();
$("saved_pct").textContent = d.saved_pct.toFixed(1) + "%";
$("saved_usd").textContent = money(d.saved);
$("actual").textContent = money(d.actual_cost);
$("baseline").textContent = money(d.baseline_cost);
$("requests").textContent = fmt(d.total_requests);
$("classifier").textContent = money(d.classifier_cost);
$("baseline_model").textContent = d.baseline_model || "—";
renderDist(d.models || {});
renderModels(d.models || {});
const unpriced = d.unpriced_models || [];
if(unpriced.length){
$("unpriced").style.display = "block";
$("unpriced").textContent =
"No price configured for: " + unpriced.join(", ") + " — costed at $0.";
} else {
$("unpriced").style.display = "none";
}
$("status").textContent = "live";
} catch(e) {
$("status").textContent = "fetch failed";
} finally {
inFlight = false;
}
}
$("refresh").addEventListener("click", refresh);
$("reset").addEventListener("click", async () => {
await fetch("/v1/stats/reset", {method:"POST"});
refresh();
});
refresh();
setInterval(() => { if(!document.hidden) refresh(); }, 2000);
🧰 Tools
🪛 ast-grep (0.45.1)

[warning] 201-201: Avoid using the initial state variable in setState
Context: setInterval(refresh, 2000)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/src/savings_dashboard.html` around lines 167 - 202,
Update refresh and the polling setup to prevent concurrent requests: track an
in-flight state, return immediately when refresh is already running or
document.hidden is true, and clear the state in all completion paths. Replace
the unconditional setInterval polling with visibility-aware scheduling that
resumes on visibility changes and preserves manual refresh behavior; do not
apply the React-specific setstate-same-var hint.

Source: Linters/SAST tools

Comment on lines +105 to +119
// 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,
};
if let Some(price) = self.price_for(model) {
classifier_cost += tokens.cost(price);
}
}
actual_cost += classifier_cost;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report unpriced classifier models in unpriced_models.

The routed-model loop pushes every model with no price into unpriced_models. The classifier loop does not. A classifier target with no [pricing] entry is therefore costed at zero and stays invisible.

docs/operations/cost_savings.md states that models which served traffic without a pricing entry are costed at zero and listed in unpriced_models so the under-count is visible. Classifier traffic currently breaks that statement. The dashboard warning box also stays hidden in this case.

🐛 Proposed fix to surface unpriced classifier models
             if let Some(price) = self.price_for(model) {
                 classifier_cost += tokens.cost(price);
+            } else if !unpriced_models.contains(model) {
+                unpriced_models.push(model.clone());
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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,
};
if let Some(price) = self.price_for(model) {
classifier_cost += tokens.cost(price);
}
}
actual_cost += classifier_cost;
// 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,
};
if let Some(price) = self.price_for(model) {
classifier_cost += tokens.cost(price);
} else if !unpriced_models.contains(model) {
unpriced_models.push(model.clone());
}
}
actual_cost += classifier_cost;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/src/savings.rs` around lines 105 - 119, Update the
classifier-cost loop in the savings calculation to append each classifier model
lacking a price to the existing unpriced_models collection, matching the
routed-model loop’s behavior. Keep its cost at zero while ensuring the model is
surfaced for reporting and dashboard warnings.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant