feat(routing): decouple budget pricing from prompt_caching (#996)

* feat(routing): budget prices cache-aware regardless of prompt_caching flag

* docs: prompt_caching is optional for the routing budget
This commit is contained in:
Adil Hafeez 2026-07-22 13:55:48 -07:00 committed by GitHub
parent 66547bcef3
commit ad92f7fbe7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 69 additions and 124 deletions

View file

@ -408,7 +408,6 @@ async fn llm_chat_inner(
context_tokens,
candidate_model: &candidate_model,
candidate_route: candidate_route.as_deref(),
caching_enabled: prompt_caching.enabled,
},
)
.await;

View file

@ -13,10 +13,11 @@
//! baseline (what staying on the session's `default_model` would have cost — priced
//! independently of the current anchor, which drifts as switches happen). An
//! outright-cheaper switch is free but never reduces that spend. The promise: this
//! conversation bills at most `max_overhead_pct`% above never-switching. With prompt
//! caching the anchor is warm, so staying is priced at its cached rate and the switch
//! pays the cache-loss delta; without caching there is no warm cache, so both the
//! baseline and the switch cost are priced at the plain uncached input rate.
//! conversation bills at most `max_overhead_pct`% above never-switching. A warm
//! anchor is priced at its *cached* rate and the switch pays the cache-loss delta:
//! provider caches are assumed real whenever the budget is active (most providers
//! cache automatically), independent of `prompt_caching.enabled`, which only
//! controls Plano's own marker injection and caching-without-budget affinity.
//!
//! The default posture is to stick. Quality and cost stay separate: the router decides
//! whether a switch *improves quality*; the overhead cap decides whether it is *affordable*.
@ -108,11 +109,6 @@ pub struct RouteFacts<'a> {
/// The model the quality router picked for this request.
pub candidate_model: &'a str,
pub candidate_route: Option<&'a str>,
/// Whether automatic prompt caching is enabled for this instance. Selects how the
/// switch-cost gate prices context reads: with caching the anchor is warm (cached
/// rate, plus warm-token credit for A→B→A returns); without it there is no warm
/// cache, so both sides are priced at their plain uncached input rate.
pub caching_enabled: bool,
}
/// The routing decision plus the session state to carry into the response side.
@ -288,7 +284,6 @@ pub async fn route(
context_tokens,
&session_default,
cfg.cache_read_discount,
facts.caching_enabled,
)
.await
.unwrap_or(0.0),
@ -309,21 +304,17 @@ pub async fn route(
ceiling_opt = Some(ceiling);
// Credit any context the candidate still has cached from an earlier visit
// this session: a return to a still-warm model re-reads only the tokens
// appended since, not the whole context (the A→B→A case). Only meaningful
// with prompt caching — without it there is no cache to return to.
candidate_warm_tokens = if facts.caching_enabled {
b.history
.iter()
.find(|v| v.model == facts.candidate_model)
.filter(|v| {
now.duration_since(v.last_used).unwrap_or(Duration::MAX)
<= warmth_window(&capability_for_model(facts.candidate_model))
})
.map(|v| v.cached_tokens.min(context_tokens))
.unwrap_or(0)
} else {
0
};
// appended since, not the whole context (the A→B→A case).
candidate_warm_tokens = b
.history
.iter()
.find(|v| v.model == facts.candidate_model)
.filter(|v| {
now.duration_since(v.last_used).unwrap_or(Duration::MAX)
<= warmth_window(&capability_for_model(facts.candidate_model))
})
.map(|v| v.cached_tokens.min(context_tokens))
.unwrap_or(0);
match orchestrator
.estimate_switch_cost_in_usd(
context_tokens,
@ -331,7 +322,6 @@ pub async fn route(
facts.candidate_model,
candidate_warm_tokens,
cfg.cache_read_discount,
facts.caching_enabled,
)
.await
{
@ -717,7 +707,6 @@ mod tests {
context_tokens: 0,
candidate_model: candidate,
candidate_route: None,
caching_enabled: true,
}
}
@ -805,52 +794,12 @@ mod tests {
context_tokens: 0,
candidate_model: "openai/pricey",
candidate_route: None,
caching_enabled: true,
};
let d = route(&orch, Some(&st), facts).await;
assert_eq!(d.model, "openai/pricey");
assert!(!d.warm);
}
// With prompt caching OFF, the anchor holds no warm cache: the switch cost is priced
// against both models' *uncached* input rates and the never-switch baseline grows at
// the default model's uncached rate — no cache-loss penalty, no warm-token credit.
#[tokio::test]
async fn caching_off_prices_switch_against_uncached_rates() {
let orch = orch_with_rates();
// Warm on `expensive` (uncached 3.0/M). Seed baseline $1.00.
seed_warm_binding(&orch, 1.0, 0.0, 30).await;
let st = routing_budget(25.0);
let facts = RouteFacts {
session_id: Some("s1"),
tenant_id: None,
prefix_hash: Some(1),
context_tokens: 0,
candidate_model: "openai/pricey",
candidate_route: None,
caching_enabled: false,
};
let d = route(&orch, Some(&st), facts).await;
assert_eq!(d.model, "openai/pricey");
assert!(d.warm);
assert_eq!(d.switches, 1);
// Baseline grows at the default's *uncached* rate: 100k x 3.0/M = $0.30 -> $1.30.
assert!(
(d.baseline_usd - 1.30).abs() < 1e-6,
"baseline {} != 1.30 (uncached default rate)",
d.baseline_usd
);
// Switch cost = 100k x (pricey 5.0 - expensive 3.0)/1M = $0.20 (both uncached),
// well under the 25% x $1.30 = $0.325 ceiling. Contrast the caching-on cost of
// 100k x (5.0 - cached 0.3)/1M = $0.47, which would be vetoed here.
assert!(
(d.switch_spend_usd - 0.20).abs() < 1e-6,
"spend {} != 0.20 (uncached-vs-uncached switch cost)",
d.switch_spend_usd
);
}
#[tokio::test]
async fn baseline_priced_on_default_not_anchor() {
// A session that started on `expensive` (default) but has since switched to
@ -959,7 +908,6 @@ mod tests {
context_tokens: 100_000,
candidate_model: candidate,
candidate_route: None,
caching_enabled: true,
};
// Turn 1 — cold start: no binding yet, the candidate is honored and becomes both

View file

@ -239,7 +239,6 @@ async fn routing_decision_inner(
context_tokens,
candidate_model: &candidate_model,
candidate_route: result.route_name.as_deref(),
caching_enabled: prompt_caching.enabled,
},
)
.await;

View file

@ -213,13 +213,12 @@ impl OrchestratorService {
/// Estimate the input-cost (USD) of switching a session from `anchor_model`
/// (the model that handled the latest request) to `candidate_model`.
///
/// When `caching_enabled`, the anchor is warm, so staying re-reads the context at its
/// *cached* rate and `candidate_warm_tokens` (context the candidate still holds from an
/// earlier visit this session) re-read at the candidate's cached rate. When caching is
/// off there is no warm cache on either side: staying re-reads at the anchor's *plain*
/// (uncached) rate and the candidate re-ingests the whole context uncached (no
/// warm-token credit), so the cost collapses to
/// `context_tokens x (candidate_uncached_rate - anchor_uncached_rate) / 1M`.
/// The anchor is warm (this is only called for warm sessions), so staying re-reads the
/// context at its *cached* rate, while `candidate_warm_tokens` (context the candidate
/// still holds from an earlier visit this session) re-read at the candidate's cached
/// rate. Provider caches are assumed real whenever the routing budget is active —
/// most providers cache automatically, with or without Plano's marker injection
/// (`prompt_caching.enabled` is not consulted here).
///
/// Fetches per-model rates from the configured cost feed; returns `None` when pricing
/// is missing for either side so the caller can fail open (switch freely) rather than
@ -232,22 +231,13 @@ impl OrchestratorService {
candidate_model: &str,
candidate_warm_tokens: u64,
cache_read_discount: f64,
caching_enabled: bool,
) -> Option<f64> {
let anchor = self.model_rates(anchor_model).await?;
let candidate = self.model_rates(candidate_model).await?;
let (anchor_read_rate, candidate_warm_tokens) = if caching_enabled {
(
anchor.cached_input_rate(cache_read_discount),
candidate_warm_tokens,
)
} else {
(anchor.input_per_million, 0)
};
Some(switch_cost_in_usd(
context_tokens,
candidate_warm_tokens,
anchor_read_rate,
anchor.cached_input_rate(cache_read_discount),
candidate.input_per_million,
candidate.cached_input_rate(cache_read_discount),
))
@ -256,26 +246,19 @@ impl OrchestratorService {
/// This turn's contribution to the session's *never-switch* baseline: the USD cost of
/// reading `context_tokens` on `model` — the session's `default_model`, i.e. what it
/// would have paid by never switching (not the possibly-drifted current anchor). Priced
/// at the cached input rate when `caching_enabled` (the never-switch path stays warm),
/// at the plain uncached rate otherwise (nothing is cached, so every turn re-reads the
/// full context). Summed across turns, this is the denominator the percentage overhead
/// cap is measured against. `None` when the model has no pricing (the caller then can't
/// grow the baseline this turn).
/// at the cached input rate: the never-switch path stays warm, and provider caches are
/// assumed real whenever the routing budget is active. Summed across turns, this is
/// the denominator the percentage overhead cap is measured against. `None` when the
/// model has no pricing (the caller then can't grow the baseline this turn).
pub async fn context_read_cost_in_usd(
&self,
context_tokens: u64,
model: &str,
cache_read_discount: f64,
caching_enabled: bool,
) -> Option<f64> {
let rates = self.model_rates(model).await?;
let context_millions = context_tokens as f64 / TOKENS_PER_MILLION;
let rate = if caching_enabled {
rates.cached_input_rate(cache_read_discount)
} else {
rates.input_per_million
};
Some(context_millions * rate)
Some(context_millions * rates.cached_input_rate(cache_read_discount))
}
// ---- LLM routing ----

View file

@ -33,9 +33,10 @@ pub struct Routing {
pub session_ttl_seconds: Option<u64>,
pub session_max_entries: Option<usize>,
pub session_cache: Option<SessionCacheConfig>,
/// Cost gate on model switching within a session. Independent of prompt caching:
/// this is a routing decision that applies whenever it is configured, whether or
/// not `prompt_caching` is enabled. Presence of this block turns it on.
/// Cost gate on model switching within a session. Self-sufficient and independent
/// of prompt caching: presence of this block turns it on, implicit sessions are
/// derived on its own, and warm anchors are always priced at cached rates —
/// `prompt_caching` only controls marker injection and affinity-without-budget.
pub routing_budget: Option<RoutingBudget>,
}