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 per-model pricing and baseline comparisons.
    • Added /v1/savings metrics and a live /dashboard with spending, savings, routing, per-model costs, warnings, refresh, and reset controls.
    • Reporting remains disabled unless pricing is configured.
  • Documentation
    • Added configuration, API, dashboard, and pricing guidance.
  • Removed
    • Removed deprecated 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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: fca1e829-616c-4216-8a72-6208b9b85737

📥 Commits

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

📒 Files selected for processing (4)
  • crates/switchyard-server/src/config.rs
  • crates/switchyard-server/src/savings.rs
  • crates/switchyard-server/src/savings_dashboard.html
  • dev-server/config.toml
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/switchyard-server/src/config.rs
  • crates/switchyard-server/src/savings_dashboard.html

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. 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, validation, 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
Attaches validated savings configuration to 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, dev-server/config.toml, CHANGELOG.md
Adds the live dashboard, documents pricing and savings behavior, adds navigation and configuration examples, and records the unreleased feature.

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

Mergeability Score: ⚪ Minimal · up to da970

The change adds opt-in cost-savings reporting without altering routing behavior when pricing is not configured, and no actionable merge-blocking risk remains beyond normal checks.

Poem

I’m a rabbit counting tokens bright,
Baselines guide the cost tonight.
Savings hop from route to route,
Unpriced models show their note.
The dashboard refreshes light!

🚥 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 main change: opt-in cost savings reporting through an endpoint and live dashboard.
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 thread crates/switchyard-server/src/config.rs
Comment thread crates/switchyard-server/src/savings_dashboard.html Outdated
Comment thread crates/switchyard-server/src/savings.rs
Signed-off-by: Michael Neale <michael.neale@gmail.com>
@ayushag-nv

Copy link
Copy Markdown
Contributor

@grahamking do we need dashboard stuff as part of switchyard ??

@grahamking

Copy link
Copy Markdown
Contributor

@michaelneale Thanks! That looks really neat.

Give us a few days to play with it. In the meantime would you be able to address the Code Rabbit review comments?

@michaelneale

Copy link
Copy Markdown
Author

thanks @grahamking on it

@michaelneale

Copy link
Copy Markdown
Author

@grahamking should be addressed now

@michaelneale

Copy link
Copy Markdown
Author

🤖 (posted by micn's AI agent)

All CodeRabbit findings are addressed in da9703b:

  • Pricing rates are validated at config build time — non-finite or negative rates are rejected at startup (with tests), matching the other numeric config validation
  • Unpriced classifier/judge models now surface in unpriced_models and the dashboard warning, per the docs
  • Dashboard polling has an in-flight guard and pauses while the tab is hidden; failed resets are reported
  • Nits taken too: fmt() on the distribution call count, round6 on per-model costs for a consistent JSON contract, and the unused is_empty removed

fmt, clippy, and all tests green. Happy to iterate while you try it out — and if the consensus lands on JSON-endpoint-only, the /dashboard page splits out cleanly.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

@michaelneale I will review the updated changes and verify the addressed findings.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ting-hong-shieh

Copy link
Copy Markdown
Contributor

Prose and accuracy review

I reviewed the public documentation and dashboard copy against the implementation, the repository's existing cost estimator, and current provider pricing. I found three actionable accuracy issues; I did not repeat the CodeRabbit findings already addressed in da9703b.

  1. [P1] Replace the Opus 4.7 pricing exampledocs/operations/cost_savings.md:16 and dev-server/config.toml.

    The documented $15 input / $75 output / $1.50 cache-read / $18.75 cache-write numbers are Opus 4.1-era rates. Anthropic's current pricing lists Opus 4.7 at $5, $25, $0.50, and $6.25 (5-minute cache write) per MTok; this repository's cost_estimator.py uses the same rates. Copying the snippet makes Opus costs 3× too high and distorts its comparison with the other model. Please update both examples, or use unmistakable placeholders with a pricing-source note.

  2. [P2] Label configured-rate results as estimatesdocs/operations/cost_savings.md:41 and the dashboard's “Actual spend” label.

    actual_cost is calculated from token counters and static user-supplied rates; it is not read from a provider bill, and unpriced traffic is explicitly counted as zero. Calling this “actual spend” overstates what the endpoint establishes. Minimal alternative: “estimated routed cost, estimated baseline cost, and their difference.” The JSON field can remain actual_cost.

  3. [P2] Present negative savings as increased costcrates/switchyard-server/src/savings_dashboard.html:72-77.

    SavingsConfig::compute sets saved = baseline_cost - actual_cost, so classifier overhead or an explicitly cheaper baseline can make saved and saved_pct negative. The dashboard nevertheless always labels and colors the result as green savings, producing output such as green “Dollars saved: -$1.00”. Please derive the labels and color from the sign—for example, switch to “Over baseline” / “Additional estimated cost” and a warning or neutral color when d.saved < 0.

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.

4 participants