feat(routing): automatic prompt caching + a per-session routing budget (#982)

* feat(routing): cache-aware routing and zero-config prompt caching

Reconcile prompt caching with intelligent routing: zero-config implicit
session affinity, cache_control preservation/injection across transforms,
cache-adjusted routing economics, and a response-driven cache-hit feedback loop.

* refactor(caching): model-aware cache markers, drop cache-aware routing

* feat(routing): session stickiness cache-regret cost gate

* refactor(routing): expose per-model input and cached rates

* fix(docker): patch libssh2 CVEs by upgrading after package install

* feat: record counterfactual route on vetoed session switches

Add opt-in session_stickiness.record_counterfactual that emits the
plano.switch.counterfactual_route OTEL attribute when the cache-regret
gate retains the previous model, capturing the route it would have taken
had the switch been allowed. Telemetry only; the candidate is never
dispatched. Includes schema, demo config, and a getting-started guide.

* refactor: group prompt-caching and session-stickiness logic into cohesive modules

Extract the two concerns interleaved in the LLM handler into dedicated
sibling modules for readability: prompt_caching.rs (cache-marker injection)
and session_stickiness.rs (session key/prefix-hash resolution, pin lookup,
cache-regret gate, and pin planning). handlers/llm/mod.rs is now a thin
orchestrator that calls them at clear phase boundaries. Behavior-preserving.

* refactor(routing): unify session routing into a cumulative switch budget

Replace observed_cache_hit + per-request threshold with time/provider-TTL
warmth and a per-session switch budget in a single session_router::route()
used by both the full-proxy and /routing decision paths.

* refactor(routing): move switch budget to routing.routing_budget and rename telemetry

* refactor(routing): drop negative-cost budget refund from routing_budget

* refactor(routing): express routing_budget as a percentage overhead cap

Replace the absolute seed_usd pool with max_overhead_pct (whole-number
percent) measured against the session's running never-switch baseline:
allow a paid switch only while cumulative switch spend stays within
max_overhead_pct% of what staying on the anchor would have cost. Track
baseline_usd and switch_spend_usd on the binding (monotonic, no refunds).

* refactor(observability): align routing-budget telemetry with the overhead cap

Rename the switch-decision reasons (within_budget/over_budget -> within_cap/
over_cap) and the session span attributes to match the percentage overhead-cap
model: budget_remaining_in_usd -> overhead_pct + switch_spend_in_usd +
baseline_in_usd, and switch threshold_in_usd -> overhead_ceiling_in_usd.

* feat(observability): emit per-request and cumulative session cost

Price each turn from the catalog rates and surface it as llm.usage.{input,
output,total}_cost_usd on the (llm) span, then accumulate into a conversation-
level session_cost_usd on the binding and emit plano.session.total_cost_in_usd
on the routing span. Cache-creation tokens are priced at the plain input rate,
and the OpenAI vs Anthropic prompt-token convention (cached folded in vs
reported separately) is captured at parse time so the cost math is correct for
both. Rates are resolved request-side since the response path is synchronous.

* refactor(routing): price the never-switch baseline against the session default model

Distinguish the session's default_model (the model it started on, i.e. what it
would have cost by never switching) from anchor_model (the model that handled
the latest request, which the session is warm on). The never-switch baseline now
grows at the default model's cached rate rather than the drifting anchor's, so
the overhead-cap denominator stays true to "% above never-switching"; switch cost
is still measured against the current anchor. Also rename estimate_context_tokens
-> actual_context_tokens (and est_context_tokens -> context_tokens) since the
router prefers the provider's real prompt-token count when the session is warm.

* feat(routing): track bounded route history and price returns to still-warm models

Record a bounded (LRU, capped) per-model visit history on the session binding
and use it to sharpen the switch-cost estimate: a return to a model still within
its cache window re-reads only the tokens appended since its last visit (at its
cached rate) instead of the whole context at the uncached rate, so an A->B->A
switch is no longer over-charged as a full re-ingest. History is carried through
the response-side refresh (refining the anchor's entry from real usage) and
cleared on prefix drift. Adds the plano.switch.candidate_warm_tokens span
attribute.

* refactor(routing): tidy test names and rename pin events to binding events

Shorten run-on test names and replace stale "pin" vocabulary in the session
binding metric (brightstaff_session_pin_events_total -> _binding_events_total),
dropping lifecycle event labels that are no longer emitted.

* feat(routing): price switch cost against uncached rates when caching is off

The overhead-cap gate previously always priced the anchor at its cached rate,
assuming a warm provider cache. When prompt caching is disabled there is no
warm cache to lose, so both the switch cost and the never-switch baseline are
now priced at the plain uncached input rate:
switch_cost = context_tokens x (candidate_uncached - anchor_uncached) / 1M.

* fix(brightstaff): bound pricing-feed fetch with timeouts and warn when session state runs without a tenant header

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(routing): validate the session overhead cap end-to-end across a multi-turn session

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(demos): fix stale pinning semantics and add routing_budget demo script

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Adil Hafeez <adil.hafeez@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Musa 2026-07-20 17:53:34 -07:00 committed by GitHub
parent 80bb044857
commit 844f08bda7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 4639 additions and 316 deletions

View file

@ -47,10 +47,13 @@ FROM docker.io/envoyproxy/envoy:${ENVOY_VERSION} AS envoy
FROM python:3.14-slim AS arch
# Install runtime deps first, then upgrade — so security patches also land on
# packages pulled in transitively by the install, not just those already present
# in the base image.
RUN set -eux; \
apt-get update; \
apt-get upgrade -y; \
apt-get install -y --no-install-recommends gettext-base procps; \
apt-get upgrade -y; \
apt-get clean; rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir supervisor

View file

@ -273,6 +273,13 @@ static_resources:
auto_host_rewrite: true
cluster: {{ cluster_name }}
timeout: 300s
{% if cluster.prefix_affinity %}
# Feeds the cluster's RING_HASH lb_policy: requests with the
# same prompt-prefix hash stick to the same replica.
hash_policy:
- header:
header_name: x-plano-prefix-hash
{% endif %}
{% endfor %}
http_filters:
- name: envoy.filters.http.router
@ -995,9 +1002,18 @@ static_resources:
{% else -%}
connect_timeout: {{ upstream_connect_timeout | default('5s') }}
{% endif -%}
{% if cluster.prefix_affinity -%}
# KV-aware replica stickiness: resolve every replica address and consistent-hash
# requests (by x-plano-prefix-hash, see the route-level hash_policy) so the same
# prompt prefix lands on the replica holding its warm KV cache.
type: STRICT_DNS
dns_lookup_family: V4_ONLY
lb_policy: RING_HASH
{% else -%}
type: LOGICAL_DNS
dns_lookup_family: V4_ONLY
lb_policy: ROUND_ROBIN
{% endif -%}
load_assignment:
cluster_name: {{ cluster_name }}
endpoints:

View file

@ -155,6 +155,13 @@ properties:
- https
http_host:
type: string
prefix_affinity:
type: boolean
description: >
For self-hosted multi-replica backends (e.g. vLLM): consistent-hash requests
to replicas by the x-plano-prefix-hash header so the same prompt prefix lands
on the same replica and reuses its KV cache. Uses ring-hash load balancing
across all resolved endpoint addresses. Default false.
additionalProperties: false
required:
- endpoint
@ -316,6 +323,33 @@ properties:
orchestrator_model_context_length:
type: integer
description: "Maximum token length for the orchestrator/routing model context window. Default is 8192."
prompt_caching:
type: object
description: >
Automatic provider prompt caching, configured once for the whole Plano instance.
Disabled by default; set enabled: true to opt in. Prompt caching never changes
which model routing selects — it only keeps a conversation on the same
model/provider so the upstream prompt cache stays warm across turns, and
auto-injects provider cache-control markers where supported.
properties:
enabled:
type: boolean
description: "Master switch. Default false (opt-in). Applies across the entire instance."
session_affinity:
type: boolean
description: "Auto-derive a session key from the prompt prefix (system + tools + first user message) when X-Model-Affinity is absent, so follow-up turns reuse the same warm cache. Default true when enabled."
inject_cache_control:
type: boolean
description: "Auto-insert ephemeral cache_control breakpoints for providers that need explicit markers (Anthropic). Default true when enabled."
min_prefix_tokens:
type: integer
minimum: 1
description: "Skip breakpoint injection when the estimated stable prefix is below this many tokens. Default 1024."
session_ttl_seconds:
type: integer
minimum: 1
description: "Pin lifetime for implicit/explicit sessions; align with the provider cache window (e.g. 3600 for Anthropic 1h caching). Defaults to routing.session_ttl_seconds."
additionalProperties: false
system_prompt:
type: string
prompt_targets:
@ -510,6 +544,51 @@ properties:
Optional HTTP header name whose value is used as a tenant prefix in the cache key.
When set, keys are scoped as plano:affinity:{tenant_id}:{session_id}.
additionalProperties: false
routing_budget:
type: object
description: >
Per-session cost gate on model switching. Independent of prompt caching — it
applies whenever configured (presence of this block turns it on). The default
posture is to stick to the model a session is warm on. When routing proposes a
different model while that session's provider cache is plausibly still warm
(inferred from the idle gap vs. the provider's cache window), the actual
input-token cost of abandoning the cache —
context_tokens x (candidate_uncached_input_rate - anchor_cached_input_rate),
output cost deliberately excluded — accrues into the session's cumulative
switch spend. A paid switch is allowed only while that spend stays within
max_overhead_pct percent of the session's running never-switch baseline (what
staying on the anchor would have cost). An outright-cheaper switch is free but
never reduces the spend. Requires a cost source in model_metrics_sources.
properties:
max_overhead_pct:
type: number
minimum: 0
description: >
Cap on cumulative switching overhead, as a percentage of what the session
would have cost by never switching (a whole number: 20 = 20%). The promise
is "this conversation bills at most max_overhead_pct% above never-switching."
0 means never pay to switch (only outright-cheaper switches are allowed);
larger values buy more quality-driven switches. Typical range 10-30.
replenish_on_rebind:
type: boolean
description: "Reset the running baseline/spend totals when a cold session re-binds. Default true."
cache_read_discount:
type: number
minimum: 0
maximum: 1
description: >
Fallback used to estimate a model's cached input rate when the pricing feed
doesn't publish one (cached_rate = input_rate x discount). A pricing detail,
not a cost policy. Default 0.1.
record_counterfactual:
type: boolean
description: >
When true, a vetoed switch records the route the gate would have taken had
the switch been allowed, as the plano.switch.counterfactual_route span
attribute. Telemetry only — the counterfactual model is never dispatched.
Useful for evals/benchmarks. Default false.
required: ["max_overhead_pct"]
additionalProperties: false
additionalProperties: false
state_storage:
type: object

View file

@ -0,0 +1,181 @@
//! Implicit session-affinity key derivation.
//!
//! When a request carries no explicit `X-Model-Affinity` header, Plano derives a
//! stable session key from the parts of the prompt that repeat verbatim at the head
//! of every turn — the same bytes the provider's prompt cache is keyed on:
//!
//! ```text
//! session_key = hash(system + tools + first_user_message)
//! prefix_hash = hash(system + tools)
//! ```
//!
//! The session key is constant for the life of a conversation (history grows at the
//! tail, not the head), so turns 2+ reuse the same pin without any client changes.
//! The prefix hash covers only the fully-stable segment and is stored with the pin
//! for drift detection: if it changes, the provider cache is already lost and
//! re-routing fresh is safe.
//!
//! Only salted hashes are ever stored — never prompt content.
use hermesllm::apis::openai::{Message, Role};
/// Salt folded into every hash so stored keys can't be trivially correlated with
/// prompt content across systems. Deterministic across processes/replicas so a
/// shared Redis session cache keys consistently.
const HASH_SALT: &str = "plano-affinity-v1";
/// Prefix distinguishing derived keys from client-supplied `X-Model-Affinity` ids.
const IMPLICIT_KEY_PREFIX: &str = "implicit:";
/// Derived affinity identifiers for one request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImplicitAffinity {
/// Session-cache key: `implicit:{hex}` over system + tools + first user message.
pub session_key: String,
/// Hash of the stable prefix only (system + tools), for drift detection.
pub prefix_hash: u64,
}
/// FNV-1a 64-bit — stable across processes and Rust versions (unlike `DefaultHasher`),
/// dependency-free, and plenty for cache keying (collisions merely over-pin, which is
/// cache-friendly).
fn fnv1a64(chunks: &[&str]) -> u64 {
const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = OFFSET_BASIS;
let mut feed = |bytes: &[u8]| {
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(PRIME);
}
// Field separator so ("ab","c") and ("a","bc") hash differently.
hash ^= 0x1f;
hash = hash.wrapping_mul(PRIME);
};
feed(HASH_SALT.as_bytes());
for chunk in chunks {
feed(chunk.as_bytes());
}
hash
}
/// Derive the implicit affinity key from parsed request messages and tool names.
///
/// Returns `None` when there is no user message to anchor on (nothing distinguishes
/// the conversation, so pinning would be meaningless).
pub fn derive_implicit_affinity(
messages: &[Message],
tool_names: Option<&[String]>,
tenant_id: Option<&str>,
) -> Option<ImplicitAffinity> {
let system_text: String = messages
.iter()
.filter(|m| matches!(m.role, Role::System | Role::Developer))
.filter_map(|m| m.content.as_ref().map(|c| c.to_string()))
.collect::<Vec<_>>()
.join("\n");
let first_user = messages
.iter()
.find(|m| matches!(m.role, Role::User))
.and_then(|m| m.content.as_ref().map(|c| c.to_string()))?;
let tools_text = tool_names.map(|names| names.join(",")).unwrap_or_default();
let tenant = tenant_id.unwrap_or_default();
let prefix_hash = fnv1a64(&[tenant, &system_text, &tools_text]);
let session_hash = fnv1a64(&[tenant, &system_text, &tools_text, &first_user]);
Some(ImplicitAffinity {
session_key: format!("{IMPLICIT_KEY_PREFIX}{session_hash:016x}"),
prefix_hash,
})
}
#[cfg(test)]
mod tests {
use super::*;
use hermesllm::apis::openai::MessageContent;
fn msg(role: Role, text: &str) -> Message {
Message {
role,
content: Some(MessageContent::Text(text.to_string())),
name: None,
tool_call_id: None,
tool_calls: None,
}
}
#[test]
fn key_is_stable_as_history_grows() {
let turn1 = vec![
msg(Role::System, "You are a coding agent."),
msg(Role::User, "Fix the bug in main.rs"),
];
let turn5 = vec![
msg(Role::System, "You are a coding agent."),
msg(Role::User, "Fix the bug in main.rs"),
msg(Role::Assistant, "Done. Anything else?"),
msg(Role::User, "Now add tests"),
msg(Role::Assistant, "Added."),
];
let a1 = derive_implicit_affinity(&turn1, None, None).unwrap();
let a5 = derive_implicit_affinity(&turn5, None, None).unwrap();
assert_eq!(a1.session_key, a5.session_key);
assert_eq!(a1.prefix_hash, a5.prefix_hash);
assert!(a1.session_key.starts_with("implicit:"));
}
#[test]
fn different_first_user_message_yields_different_session() {
let base = msg(Role::System, "You are a coding agent.");
let a = derive_implicit_affinity(
&[base.clone(), msg(Role::User, "conversation A")],
None,
None,
)
.unwrap();
let b = derive_implicit_affinity(&[base, msg(Role::User, "conversation B")], None, None)
.unwrap();
// Same stable prefix, different conversations.
assert_eq!(a.prefix_hash, b.prefix_hash);
assert_ne!(a.session_key, b.session_key);
}
#[test]
fn changed_system_prompt_changes_prefix_hash() {
let a = derive_implicit_affinity(
&[msg(Role::System, "v1 prompt"), msg(Role::User, "hi")],
None,
None,
)
.unwrap();
let b = derive_implicit_affinity(
&[msg(Role::System, "v2 prompt"), msg(Role::User, "hi")],
None,
None,
)
.unwrap();
assert_ne!(a.prefix_hash, b.prefix_hash);
assert_ne!(a.session_key, b.session_key);
}
#[test]
fn tools_and_tenant_are_part_of_the_key() {
let messages = [msg(Role::System, "s"), msg(Role::User, "u")];
let plain = derive_implicit_affinity(&messages, None, None).unwrap();
let with_tools =
derive_implicit_affinity(&messages, Some(&["get_weather".to_string()]), None).unwrap();
let with_tenant = derive_implicit_affinity(&messages, None, Some("acme")).unwrap();
assert_ne!(plain.session_key, with_tools.session_key);
assert_ne!(plain.session_key, with_tenant.session_key);
}
#[test]
fn no_user_message_yields_none() {
assert!(derive_implicit_affinity(&[msg(Role::System, "s")], None, None).is_none());
assert!(derive_implicit_affinity(&[], None, None).is_none());
}
}

View file

@ -1,7 +1,10 @@
use std::collections::HashMap;
use std::sync::Arc;
use common::configuration::{Agent, FilterPipeline, Listener, ModelAlias, SpanAttributes};
use common::configuration::{
Agent, EffectivePromptCaching, EffectiveRoutingBudget, FilterPipeline, Listener, ModelAlias,
SpanAttributes,
};
use common::llm_providers::LlmProviders;
use tokio::sync::RwLock;
@ -31,4 +34,10 @@ pub struct AppState {
/// When false, agentic signal analysis is skipped on LLM responses to save CPU.
/// Controlled by `overrides.disable_signals` in plano config.
pub signals_enabled: bool,
/// Instance-wide automatic prompt-caching settings, resolved once from the
/// top-level `prompt_caching` config. Disabled by default (opt-in).
pub prompt_caching: EffectivePromptCaching,
/// Per-session model-switch cost gate, resolved from `routing.routing_budget`.
/// Independent of prompt caching; `None` when not configured (off by default).
pub routing_budget: Option<EffectiveRoutingBudget>,
}

View file

@ -1239,6 +1239,7 @@ impl ArchFunctionHandler {
total_tokens: 0,
prompt_tokens_details: None,
completion_tokens_details: None,
..Default::default()
},
system_fingerprint: None,
service_tier: None,

View file

@ -1,6 +1,9 @@
use bytes::Bytes;
use common::configuration::{FilterPipeline, ModelAlias};
use common::consts::{ARCH_IS_STREAMING_HEADER, ARCH_PROVIDER_HINT_HEADER, MODEL_AFFINITY_HEADER};
use common::consts::{
ARCH_IS_STREAMING_HEADER, ARCH_PROVIDER_HINT_HEADER, MODEL_AFFINITY_HEADER, PLANO_CACHE_HEADER,
PLANO_PREFIX_HASH_HEADER,
};
use common::llm_providers::LlmProviders;
use hermesllm::apis::openai::Message;
use hermesllm::apis::openai_responses::InputParam;
@ -19,6 +22,8 @@ use tokio::sync::RwLock;
use tracing::{debug, info, info_span, warn, Instrument};
pub(crate) mod model_selection;
pub(crate) mod prompt_caching;
pub(crate) mod session_router;
use crate::app_state::AppState;
use crate::handlers::agents::pipeline::PipelineProcessor;
@ -31,7 +36,7 @@ use crate::state::{
};
use crate::streaming::{
create_streaming_response, create_streaming_response_with_output_filter, truncate_message,
LlmMetricsCtx, ObservableStreamProcessor, StreamProcessor,
LlmMetricsCtx, ObservableStreamProcessor, SessionUpdateCtx, StreamProcessor,
};
use crate::tracing::{
collect_custom_trace_attributes, llm as tracing_llm, operation_component,
@ -112,8 +117,7 @@ async fn llm_chat_inner(
}
}
// Session pinning: extract session ID and check cache before routing
let session_id: Option<String> = request_headers
let explicit_session_id: Option<String> = request_headers
.get(MODEL_AFFINITY_HEADER)
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
@ -123,40 +127,17 @@ async fn llm_chat_inner(
.and_then(|hdr| request_headers.get(hdr))
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let cached_route = if let Some(ref sid) = session_id {
state
.orchestrator_service
.get_cached_route(sid, tenant_id.as_deref())
.await
} else {
None
};
let (pinned_model, pinned_route_name): (Option<String>, Option<String>) = match cached_route {
Some(c) => (Some(c.model_name), c.route_name),
None => (None, None),
};
// Record session id on the LLM span for the observability console.
if let Some(ref sid) = session_id {
get_active_span(|span| {
span.set_attribute(opentelemetry::KeyValue::new(
tracing_plano::SESSION_ID,
sid.clone(),
));
});
}
if let Some(ref route_name) = pinned_route_name {
get_active_span(|span| {
span.set_attribute(opentelemetry::KeyValue::new(
tracing_plano::ROUTE_NAME,
route_name.clone(),
));
});
}
// `X-Plano-Cache: off` disables implicit pinning + marker injection for one call.
let cache_off_for_request = request_headers
.get(PLANO_CACHE_HEADER)
.and_then(|h| h.to_str().ok())
.is_some_and(|v| v.eq_ignore_ascii_case("off"));
let full_qualified_llm_provider_url = format!("{}{}", state.llm_provider_url, request_path);
// --- Phase 1: Parse and validate the incoming request ---
// (Parsing happens before session-key derivation: the implicit affinity key is
// computed from the request body's stable prompt prefix.)
let parsed = match parse_and_validate_request(
request,
&request_path,
@ -187,6 +168,14 @@ async fn llm_chat_inner(
provider_id,
} = parsed;
// Prompt caching is configured once for the whole instance and never influences
// which model routing selects — it only keeps a conversation on the same
// model/provider so the upstream prompt cache stays warm across turns. Note:
// session affinity and pin lookup are derived later (Phase 2a), *after* input
// filters and Responses-API state merge, so the prefix hash reflects the exact
// stable prefix sent upstream rather than the pre-filter request body.
let prompt_caching = state.prompt_caching;
// Record LLM-specific span attributes
let span = tracing::Span::current();
if let Some(temp) = temperature {
@ -283,6 +272,18 @@ async fn llm_chat_inner(
*bad_request.status_mut() = StatusCode::BAD_REQUEST;
return Ok(bad_request);
}
// Auto-inject prompt-cache markers (grouped in `prompt_caching`). No-op unless
// caching is enabled for this request and the model needs explicit markers.
prompt_caching::inject_cache_markers(
&mut client_request,
provider_id,
&model_name_only,
&upstream_api,
&prompt_caching,
cache_off_for_request,
&alias_resolved_model,
);
}
// --- Phase 2: Resolve conversation state (v1/responses API) ---
@ -301,6 +302,40 @@ async fn llm_chat_inner(
Err(response) => return Ok(response),
};
// --- Phase 2a: Session affinity (see `session_router`) ---
// Derived here, after input filters and Responses-API state merge, so the
// implicit session key and prefix hash are computed over the exact stable
// prefix (system + tools + first user message) that is actually sent upstream —
// the same bytes the provider's prompt cache is keyed on. The prefix hash is
// derived even when caching is off, so the `x-plano-prefix-hash` RING_HASH
// replica-stickiness header still works.
let routing_budget = state.routing_budget;
// Derive the implicit session key when either prompt-caching affinity or the
// routing budget is active — the budget needs a session anchor even with caching off.
let implicit_affinity_enabled = prompt_caching.session_affinity || routing_budget.is_some();
let request_messages = client_request.get_messages();
let session_router::SessionResolution {
request_prefix_hash,
session_id,
} = session_router::resolve_session(
explicit_session_id,
&request_messages,
tool_names.as_deref(),
tenant_id.as_deref(),
implicit_affinity_enabled,
cache_off_for_request,
);
// Record session id on the LLM span for the observability console.
if let Some(ref sid) = session_id {
get_active_span(|span| {
span.set_attribute(opentelemetry::KeyValue::new(
tracing_plano::SESSION_ID,
sid.clone(),
));
});
}
// Serialize request for upstream BEFORE router consumes it
let client_request_bytes_for_upstream: Bytes =
match ProviderRequestType::to_bytes(&client_request) {
@ -313,76 +348,86 @@ async fn llm_chat_inner(
}
};
// --- Phase 3: Route the request (or use pinned model from session cache) ---
let resolved_model = if let Some(cached_model) = pinned_model {
info!(
session_id = %session_id.as_deref().unwrap_or(""),
model = %cached_model,
"using pinned routing decision from cache"
);
cached_model
// Context size for the switch-cost estimate, computed before the router consumes
// the request. Only needed when stickiness could act on a session.
let context_tokens: u64 = if session_id.is_some() && routing_budget.is_some() {
session_router::actual_context_tokens(&request_messages, &model_name_only)
} else {
let routing_span = info_span!(
"routing",
component = "routing",
http.method = "POST",
http.target = %request_path,
model.requested = %model_from_request,
model.alias_resolved = %alias_resolved_model,
route.selected_model = tracing::field::Empty,
routing.determination_ms = tracing::field::Empty,
);
let routing_result = match async {
set_service_name(operation_component::ROUTING);
router_chat_get_upstream_model(
Arc::clone(&state.orchestrator_service),
client_request,
&request_path,
&request_id,
inline_routing_preferences,
)
.await
}
.instrument(routing_span)
.await
{
Ok(result) => result,
Err(err) => {
let mut internal_error = Response::new(full(err.message));
*internal_error.status_mut() = err.status_code;
return Ok(internal_error);
}
};
let (router_selected_model, route_name) =
(routing_result.model_name, routing_result.route_name);
let model = if router_selected_model != "none" {
router_selected_model
} else {
alias_resolved_model.clone()
};
// Record route name on the LLM span (only when the orchestrator produced one).
if let Some(ref rn) = route_name {
if !rn.is_empty() && rn != "none" {
get_active_span(|span| {
span.set_attribute(opentelemetry::KeyValue::new(
tracing_plano::ROUTE_NAME,
rn.clone(),
));
});
}
}
if let Some(ref sid) = session_id {
state
.orchestrator_service
.cache_route(sid.clone(), tenant_id.as_deref(), model.clone(), route_name)
.await;
}
model
0
};
// --- Phase 3: Route the request (quality), then apply the session-cache decision ---
// Routing stays cache-blind: the quality router always picks a candidate. The
// session router then honors it or sticks to the warm anchor (see `session_router`).
let routing_span = info_span!(
"routing",
component = "routing",
http.method = "POST",
http.target = %request_path,
model.requested = %model_from_request,
model.alias_resolved = %alias_resolved_model,
route.selected_model = tracing::field::Empty,
routing.determination_ms = tracing::field::Empty,
);
let routing_result = match async {
set_service_name(operation_component::ROUTING);
router_chat_get_upstream_model(
Arc::clone(&state.orchestrator_service),
client_request,
&request_path,
&request_id,
inline_routing_preferences,
)
.await
}
.instrument(routing_span)
.await
{
Ok(result) => result,
Err(err) => {
let mut internal_error = Response::new(full(err.message));
*internal_error.status_mut() = err.status_code;
return Ok(internal_error);
}
};
let candidate_model = if routing_result.model_name != "none" {
routing_result.model_name
} else {
alias_resolved_model.clone()
};
let candidate_route = routing_result.route_name;
let decision = session_router::route(
&state.orchestrator_service,
routing_budget.as_ref(),
session_router::RouteFacts {
session_id: session_id.as_deref(),
tenant_id: tenant_id.as_deref(),
prefix_hash: request_prefix_hash,
context_tokens,
candidate_model: &candidate_model,
candidate_route: candidate_route.as_deref(),
caching_enabled: prompt_caching.enabled,
},
)
.await;
let resolved_model = decision.model;
let resolved_route_name = decision.route_name;
// Record route name on the LLM span (only when a real route was produced).
if let Some(ref rn) = resolved_route_name {
if !rn.is_empty() && rn != "none" {
get_active_span(|span| {
span.set_attribute(opentelemetry::KeyValue::new(
tracing_plano::ROUTE_NAME,
rn.clone(),
));
});
}
}
tracing::Span::current().record(tracing_llm::MODEL_NAME, resolved_model.as_str());
// Record the provider (derived from the `provider/model` prefix) so
@ -398,6 +443,57 @@ async fn llm_chat_inner(
});
}
// Resolve the dispatched model's catalog rates now (this side is async; the
// response path that prices the turn is synchronous). `None` when no cost feed is
// configured → no per-request/session cost is computed.
let cost_rates = if session_id.is_some() {
state
.orchestrator_service
.model_rates(&resolved_model)
.await
} else {
None
};
let cache_read_discount = state
.routing_budget
.as_ref()
.map(|b| b.cache_read_discount)
.unwrap_or(common::configuration::DEFAULT_CACHE_READ_DISCOUNT);
// Response-side refresh: update `last_used` + the context-size estimate from the
// real response. The routing decision itself was already persisted by `route()`.
let session_update_ctx: Option<SessionUpdateCtx> =
session_id.as_ref().map(|sid| SessionUpdateCtx {
orchestrator: Arc::clone(&state.orchestrator_service),
session_id: sid.clone(),
tenant_id: tenant_id.clone(),
anchor_model: resolved_model.clone(),
default_model: decision.default_model.clone(),
route_name: resolved_route_name.clone(),
prefix_hash: request_prefix_hash,
baseline_usd: decision.baseline_usd,
switch_spend_usd: decision.switch_spend_usd,
switches: decision.switches,
history: decision.history.clone(),
session_cost_usd: decision.session_cost_usd,
cost_rates,
cache_read_discount,
context_tokens: decision.cached_tokens,
gc_ttl: decision.gc_ttl,
});
// Forward the prefix hash so self-hosted multi-replica backends can do
// KV-aware replica stickiness (consistent-hash on this header at the
// cluster layer).
if let Some(hash) = request_prefix_hash {
if let Ok(val) = header::HeaderValue::from_str(&format!("{hash:016x}")) {
request_headers.insert(
header::HeaderName::from_static(PLANO_PREFIX_HASH_HEADER),
val,
);
}
}
// --- Phase 4: Forward to upstream and stream back ---
send_upstream(
&state.http_client,
@ -415,6 +511,7 @@ async fn llm_chat_inner(
state.state_storage.clone(),
request_id,
&state.filter_pipeline,
session_update_ctx,
)
.await
}
@ -692,6 +789,7 @@ async fn send_upstream(
state_storage: Option<Arc<dyn StateStorage>>,
request_id: String,
filter_pipeline: &Arc<FilterPipeline>,
session_update_ctx: Option<SessionUpdateCtx>,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let span_name = if model_from_request == resolved_model {
format!("POST {} {}", request_path, resolved_model)
@ -807,7 +905,7 @@ async fn send_upstream(
let byte_stream = llm_response.bytes_stream();
// Create base processor for metrics and tracing
let base_processor = ObservableStreamProcessor::new(
let mut base_processor = ObservableStreamProcessor::new(
operation_component::LLM,
span_name,
request_start_time,
@ -818,6 +916,13 @@ async fn send_upstream(
model: metric_model.clone(),
upstream_status: upstream_status.as_u16(),
});
// Only refresh the session binding for successful responses — errors carry no
// usage block and shouldn't reset the warmth clock.
if upstream_status.is_success() {
if let Some(update_ctx) = session_update_ctx {
base_processor = base_processor.with_session_update(update_ctx);
}
}
let output_filter_request_headers = if filter_pipeline.has_output_filters() {
Some(request_headers.clone())

View file

@ -0,0 +1,70 @@
//! Prompt-caching request handling for the LLM path.
//!
//! This module owns the "make the upstream provider's prompt cache work" concern:
//! resolving the correct cache-marking strategy for the `(gateway × model family ×
//! upstream API)` combination and injecting the markers into the outbound request.
//! It never influences routing — see [`super::session_router`] for the session-cache
//! lookup and the routing-budget switch-cost concern.
use common::configuration::EffectivePromptCaching;
use hermesllm::clients::SupportedUpstreamAPIs;
use hermesllm::{cache_marker_strategy, CacheMarkerStrategy, ProviderId, ProviderRequestType};
use tracing::debug;
/// Auto-inject prompt-cache markers into `client_request`.
///
/// The strategy is resolved from `(gateway × model family × upstream API)` so that
/// Anthropic-family models cache whether they arrive over the native Messages API or
/// an OpenAI-compatible gateway (DigitalOcean, OpenRouter), while OpenAI-family models
/// (which cache automatically) and unimplemented backends are left untouched.
///
/// A no-op when caching is disabled, `inject_cache_control` is off, the request opted
/// out (`X-Plano-Cache: off`), or the provider caches automatically. Injection is
/// idempotent (client-supplied markers are respected) and threshold-guarded against
/// the provider's minimum cacheable prefix.
pub fn inject_cache_markers(
client_request: &mut ProviderRequestType,
provider_id: ProviderId,
model_name_only: &str,
upstream_api: &SupportedUpstreamAPIs,
prompt_caching: &EffectivePromptCaching,
cache_off_for_request: bool,
alias_resolved_model: &str,
) {
if !(prompt_caching.enabled && prompt_caching.inject_cache_control && !cache_off_for_request) {
return;
}
match cache_marker_strategy(provider_id, model_name_only, upstream_api) {
CacheMarkerStrategy::AnthropicMessagesBreakpoints {
min_prefix_tokens, ..
} => {
if let ProviderRequestType::MessagesRequest(req) = client_request {
let threshold = prompt_caching.min_prefix_tokens.max(min_prefix_tokens);
if req.inject_cache_breakpoints(threshold) {
debug!(
model = %alias_resolved_model,
min_prefix_tokens = threshold,
"injected anthropic ephemeral cache breakpoints"
);
}
}
}
CacheMarkerStrategy::OpenAiContentPartCacheControl {
min_prefix_tokens,
ttl,
} => {
if let ProviderRequestType::ChatCompletionsRequest(req) = client_request {
let threshold = prompt_caching.min_prefix_tokens.max(min_prefix_tokens);
if req.inject_cache_control(ttl, threshold) {
debug!(
model = %alias_resolved_model,
min_prefix_tokens = threshold,
"injected openai content-part cache_control"
);
}
}
}
CacheMarkerStrategy::Automatic | CacheMarkerStrategy::None => {}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,11 @@
use bytes::Bytes;
use common::configuration::{SpanAttributes, TopLevelRoutingPreference};
use common::configuration::{
EffectivePromptCaching, EffectiveRoutingBudget, SpanAttributes, TopLevelRoutingPreference,
};
use common::consts::{MODEL_AFFINITY_HEADER, REQUEST_ID_HEADER};
use common::errors::BrightStaffError;
use hermesllm::clients::SupportedAPIsFromClient;
use hermesllm::ProviderRequestType;
use hermesllm::{ProviderRequest, ProviderRequestType};
use http_body_util::combinators::BoxBody;
use http_body_util::{BodyExt, Full};
use hyper::{Request, Response, StatusCode};
@ -12,6 +14,7 @@ use tracing::{debug, info, info_span, warn, Instrument};
use super::extract_or_generate_traceparent;
use crate::handlers::llm::model_selection::router_chat_get_upstream_model;
use crate::handlers::llm::session_router;
use crate::metrics as bs_metrics;
use crate::metrics::labels as metric_labels;
use crate::router::orchestrator::OrchestratorService;
@ -60,11 +63,14 @@ struct RoutingDecisionResponse {
pinned: bool,
}
#[allow(clippy::too_many_arguments)]
pub async fn routing_decision(
request: Request<hyper::body::Incoming>,
orchestrator_service: Arc<OrchestratorService>,
request_path: String,
span_attributes: &Option<SpanAttributes>,
prompt_caching: EffectivePromptCaching,
routing_budget: Option<EffectiveRoutingBudget>,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let request_headers = request.headers().clone();
let request_id: String = request_headers
@ -73,7 +79,7 @@ pub async fn routing_decision(
.map(|s| s.to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let session_id: Option<String> = request_headers
let explicit_session_id: Option<String> = request_headers
.get(MODEL_AFFINITY_HEADER)
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
@ -101,8 +107,10 @@ pub async fn routing_decision(
request_path,
request_headers,
custom_attrs,
session_id,
explicit_session_id,
tenant_id,
prompt_caching,
routing_budget,
)
.instrument(request_span)
.await
@ -116,8 +124,10 @@ async fn routing_decision_inner(
request_path: String,
request_headers: hyper::HeaderMap,
custom_attrs: std::collections::HashMap<String, String>,
session_id: Option<String>,
explicit_session_id: Option<String>,
tenant_id: Option<String>,
prompt_caching: EffectivePromptCaching,
routing_budget: Option<EffectiveRoutingBudget>,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
set_service_name(operation_component::ROUTING);
opentelemetry::trace::get_active_span(|span| {
@ -135,37 +145,9 @@ async fn routing_decision_inner(
.unwrap_or("unknown")
.to_string();
if let Some(ref sid) = session_id {
if let Some(cached) = orchestrator_service
.get_cached_route(sid, tenant_id.as_deref())
.await
{
info!(
session_id = %sid,
model = %cached.model_name,
route = ?cached.route_name,
"returning pinned routing decision from cache"
);
let response = RoutingDecisionResponse {
models: vec![cached.model_name],
route: cached.route_name,
trace_id,
session_id: Some(sid.clone()),
pinned: true,
};
let json = serde_json::to_string(&response).unwrap();
let body = Full::new(Bytes::from(json))
.map_err(|never| match never {})
.boxed();
return Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(body)
.unwrap());
}
}
// Parse request body
// Parse the request body up front so a pin can be validated against prefix drift
// and a fresh pin can be stored with its prefix hash. This endpoint shares the
// session cache with the LLM handler, so both must key drift the same way.
let raw_bytes = request.collect().await?.to_bytes();
debug!(
@ -202,6 +184,39 @@ async fn routing_decision_inner(
}
};
// `X-Plano-Cache: off` opts this request out of implicit affinity (same sentinel
// the LLM handler honors), so callers can bypass stickiness per request.
let cache_off_for_request = request_headers
.get(common::consts::PLANO_CACHE_HEADER)
.and_then(|h| h.to_str().ok())
.is_some_and(|v| v.eq_ignore_ascii_case("off"));
let request_messages = client_request.get_messages();
let tool_names = client_request.get_tool_names();
// Session key + prefix hash resolved identically to the LLM handler so pins
// interoperate across the full-proxy and decision paths.
// Derive the implicit session key when either prompt-caching affinity or the routing
// budget is active, so the budget works the same way with caching off.
let implicit_affinity_enabled = prompt_caching.session_affinity || routing_budget.is_some();
let session_router::SessionResolution {
request_prefix_hash,
session_id,
} = session_router::resolve_session(
explicit_session_id,
&request_messages,
tool_names.as_deref(),
tenant_id.as_deref(),
implicit_affinity_enabled,
cache_off_for_request,
);
let context_tokens: u64 = if session_id.is_some() && routing_budget.is_some() {
session_router::actual_context_tokens(&request_messages, client_request.model())
} else {
0
};
let routing_result = router_chat_get_upstream_model(
Arc::clone(&orchestrator_service),
client_request,
@ -213,23 +228,38 @@ async fn routing_decision_inner(
match routing_result {
Ok(result) => {
if let Some(ref sid) = session_id {
orchestrator_service
.cache_route(
sid.clone(),
tenant_id.as_deref(),
result.model_name.clone(),
result.route_name.clone(),
)
.await;
let candidate_model = result.model_name.clone();
let decision = session_router::route(
&orchestrator_service,
routing_budget.as_ref(),
session_router::RouteFacts {
session_id: session_id.as_deref(),
tenant_id: tenant_id.as_deref(),
prefix_hash: request_prefix_hash,
context_tokens,
candidate_model: &candidate_model,
candidate_route: result.route_name.as_deref(),
caching_enabled: prompt_caching.enabled,
},
)
.await;
// Front the ranked fallback list with the decided model (the anchor, when a
// switch was vetoed), so 429/5xx fallbacks still work.
let mut models = result.models;
if models.first() != Some(&decision.model) {
models.retain(|m| m != &decision.model);
models.insert(0, decision.model.clone());
}
let response = RoutingDecisionResponse {
models: result.models,
route: result.route_name,
models,
route: decision.route_name,
trace_id,
session_id,
pinned: false,
// `pinned` signals a warm, stuck session — safe for callers to treat as
// "keep this provider's cache warm".
pinned: decision.warm,
};
// Distinguish "decision served" (a concrete model picked) from
@ -247,6 +277,7 @@ async fn routing_decision_inner(
primary_model = %response.models.first().map(|s| s.as_str()).unwrap_or("none"),
total_models = response.models.len(),
route = ?response.route,
pinned = response.pinned,
"routing decision completed"
);

View file

@ -1,3 +1,4 @@
pub mod affinity;
pub mod app_state;
pub mod handlers;
pub mod metrics;

View file

@ -216,7 +216,14 @@ async fn init_app_state(
if latency_count > 1 {
return Err("model_metrics_sources: only one latency metrics source is allowed".into());
}
let svc = ModelMetricsService::new(sources, reqwest::Client::new()).await;
// The initial pricing fetch is awaited before listeners come up, so bound it —
// an unresponsive feed (models.dev / DO catalog) must not hang startup. The
// same client backs the periodic refresh loop.
let metrics_client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(30))
.build()?;
let svc = ModelMetricsService::new(sources, metrics_client).await;
Some(Arc::new(svc))
} else {
None
@ -343,6 +350,55 @@ async fn init_app_state(
let signals_enabled = !overrides.disable_signals.unwrap_or(false);
let prompt_caching =
common::configuration::EffectivePromptCaching::from_config(config.prompt_caching.as_ref())?;
// Routing budget lives under `routing` and is independent of prompt caching.
let routing_budget = common::configuration::EffectiveRoutingBudget::from_config(
config
.routing
.as_ref()
.and_then(|r| r.routing_budget.as_ref()),
)?;
// The routing-budget cost gate needs per-model pricing to compute switch cost.
if routing_budget.is_some() {
use common::configuration::MetricsSource;
let has_cost_source = config
.model_metrics_sources
.as_deref()
.unwrap_or_default()
.iter()
.any(|s| matches!(s, MetricsSource::Cost(_)));
if !has_cost_source {
return Err(
"routing.routing_budget is configured but no cost metrics source is \
configured add a cost source (e.g. models.dev) to model_metrics_sources so \
per-model input/cached rates are available for the switch-cost calculation"
.into(),
);
}
}
// Session bindings (anchor model, budget spend) are keyed by tenant + session.
// Without a tenant header, sessions from different customers share one keyspace,
// so identical prompts from different tenants would collide on the same binding.
let session_state_in_use = prompt_caching.session_affinity || routing_budget.is_some();
if session_state_in_use
&& config
.routing
.as_ref()
.and_then(|r| r.session_cache.as_ref())
.and_then(|c| c.tenant_header.as_ref())
.is_none()
{
warn!(
"session affinity / routing budget is enabled but routing.session_cache.tenant_header \
is not set all requests share one session keyspace; set tenant_header to isolate \
sessions per customer in multi-tenant deployments"
);
}
Ok(AppState {
orchestrator_service,
model_aliases: config.model_aliases.clone(),
@ -356,6 +412,8 @@ async fn init_app_state(
http_client: reqwest::Client::new(),
filter_pipeline,
signals_enabled,
prompt_caching,
routing_budget,
})
}
@ -527,6 +585,8 @@ async fn dispatch(
Arc::clone(&state.orchestrator_service),
stripped,
&state.span_attributes,
state.prompt_caching,
state.routing_budget,
)
.with_context(parent_cx)
.await;

View file

@ -18,6 +18,10 @@ pub const ROUTE_LLM: &str = "llm";
// Token kind for brightstaff_llm_tokens_total.
pub const TOKEN_KIND_PROMPT: &str = "prompt";
pub const TOKEN_KIND_COMPLETION: &str = "completion";
/// Input tokens served from the provider's prompt cache (billed at the cached rate).
pub const TOKEN_KIND_CACHE_READ: &str = "cache_read";
/// Input tokens written into the provider's prompt cache (cache-creation surcharge).
pub const TOKEN_KIND_CACHE_WRITE: &str = "cache_write";
// LLM error_class values (match docstring in metrics/mod.rs).
pub const LLM_ERR_NONE: &str = "none";
@ -36,3 +40,31 @@ pub const ROUTING_SVC_POLICY_ERROR: &str = "policy_error";
pub const SESSION_CACHE_HIT: &str = "hit";
pub const SESSION_CACHE_MISS: &str = "miss";
pub const SESSION_CACHE_STORE: &str = "store";
// Prompt cache outcome values (brightstaff_prompt_cache_requests_total).
pub const PROMPT_CACHE_HIT: &str = "hit";
pub const PROMPT_CACHE_MISS: &str = "miss";
// Session binding lifecycle events (brightstaff_session_binding_events_total).
/// An existing binding was refreshed from observed usage (TTL extended, token counts
/// and running cost updated) after a turn completed.
pub const BINDING_EVENT_REFRESH: &str = "refresh";
// Session-stickiness decisions (brightstaff_session_switch_decisions_total).
// `decision` label — the coarse outcome:
/// The proposed switch was honored (free, within the overhead cap, or unpriced fail-open).
pub const SWITCH_DECISION_ALLOWED: &str = "allowed";
/// The switch would have exceeded the session's overhead cap — the warm anchor was retained.
pub const SWITCH_DECISION_RETAINED: &str = "retained";
// `reason` label — why the decision was made:
/// The router agreed with the warm anchor; no switch was needed.
pub const SWITCH_REASON_SAME_ANCHOR: &str = "same_anchor";
/// The candidate was outright cheaper (negative cost) — a free switch.
pub const SWITCH_REASON_FREE: &str = "free";
/// A paid switch that kept cumulative spend within the session's overhead cap.
pub const SWITCH_REASON_WITHIN_CAP: &str = "within_cap";
/// A paid switch that would have pushed cumulative spend over the overhead cap — retained.
pub const SWITCH_REASON_OVER_CAP: &str = "over_cap";
/// Pricing was missing for one side, so the switch was allowed without a cost gate.
pub const SWITCH_REASON_NO_PRICING: &str = "no_pricing";

View file

@ -18,6 +18,9 @@
//! `brightstaff_router_decision_duration_seconds`,
//! `brightstaff_routing_service_requests_total`,
//! `brightstaff_session_cache_events_total`.
//! - Prompt caching: `brightstaff_prompt_cache_requests_total`,
//! `brightstaff_session_binding_events_total` (cache read/write tokens are emitted as
//! `kind=cache_read|cache_write` on `brightstaff_llm_tokens_total`).
//! - Process: via `metrics-process`.
//! - Build: `brightstaff_build_info`.
@ -172,6 +175,16 @@ fn describe_all() {
"brightstaff_session_cache_events_total",
"Session affinity cache lookups and stores, by outcome."
);
describe_counter!(
"brightstaff_prompt_cache_requests_total",
"LLM responses with usage data, by provider, model and prompt-cache outcome \
(hit = cache read/creation tokens reported, miss = none). The miss rate is \
the silent cache-miss baseline."
);
describe_counter!(
"brightstaff_session_binding_events_total",
"Session binding lifecycle events, by event (currently: refresh)."
);
describe_gauge!(
"brightstaff_build_info",
@ -375,3 +388,37 @@ pub fn record_session_cache_event(outcome: &'static str) {
)
.increment(1);
}
/// Record whether a completed LLM response reported prompt-cache activity.
/// `hit` means the provider billed cache-read or cache-creation tokens.
pub fn record_prompt_cache_outcome(provider: &str, model: &str, outcome: &'static str) {
counter!(
"brightstaff_prompt_cache_requests_total",
"provider" => provider.to_string(),
"model" => model.to_string(),
"outcome" => outcome,
)
.increment(1);
}
/// Record a session binding lifecycle event (see `metrics::labels::BINDING_EVENT_*`).
pub fn record_session_binding_event(event: &'static str) {
counter!(
"brightstaff_session_binding_events_total",
"event" => event,
)
.increment(1);
}
/// Record a session-stickiness decision on a proposed model switch. `decision` is the
/// coarse outcome (`allowed`/`retained`, see `metrics::labels::SWITCH_DECISION_*`) and
/// `reason` explains why (`same_anchor`/`free`/`within_cap`/`over_cap`/`no_pricing`,
/// see `metrics::labels::SWITCH_REASON_*`).
pub fn record_session_switch_decision(decision: &'static str, reason: &'static str) {
counter!(
"brightstaff_session_switch_decisions_total",
"decision" => decision,
"reason" => reason,
)
.increment(1);
}

View file

@ -11,14 +11,85 @@ use tracing::{debug, info, warn};
const DO_PRICING_URL: &str = "https://api.digitalocean.com/v2/gen-ai/models/catalog";
const MODELS_DEV_URL: &str = "https://models.dev/api.json";
/// DigitalOcean publishes prices per token; scale to the per-million convention
/// that `ModelRates` and the absolute-USD consumers (switch-cost gate) expect.
const TOKENS_PER_MILLION: f64 = 1_000_000.0;
/// Structured per-million-token USD rates for one model, as published by the cost
/// feed. Kept alongside the blended ranking metric so cost-sensitive features (e.g.
/// the session-stickiness switch-cost gate) can reason about input vs cached-input
/// pricing without touching `rank_models`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModelRates {
pub input_per_million: f64,
pub output_per_million: f64,
/// Cached (prompt-cache read) input rate. Present when the feed publishes it
/// (models.dev `cost.cache_read`); absent for feeds that don't (DO catalog).
pub cache_read_per_million: Option<f64>,
}
impl ModelRates {
/// Blended input+output metric used for `prefer: cheapest` ranking.
fn blended(&self) -> f64 {
self.input_per_million + self.output_per_million
}
/// Cached input rate, falling back to `input * cache_read_discount` when the
/// feed doesn't publish a cached rate.
pub fn cached_input_rate(&self, cache_read_discount: f64) -> f64 {
self.cache_read_per_million
.unwrap_or(self.input_per_million * cache_read_discount)
}
/// Actual USD cost of one request from observed token usage, split into
/// `(input_cost, output_cost)`. Cache *creation* tokens are priced at the plain
/// input rate (no write premium). Cache *read* tokens use the cached rate.
///
/// `prompt_includes_cached` reflects the provider convention: OpenAI-shape usage
/// folds cached tokens into `prompt_tokens` (uncached = prompt - cached), while
/// Anthropic-shape reports uncached input separately (uncached = prompt_tokens).
pub fn request_cost_usd(
&self,
prompt_tokens: u64,
cached_input_tokens: u64,
cache_creation_tokens: u64,
completion_tokens: u64,
prompt_includes_cached: bool,
cache_read_discount: f64,
) -> (f64, f64) {
let read_rate = self.cached_input_rate(cache_read_discount);
let uncached = if prompt_includes_cached {
prompt_tokens.saturating_sub(cached_input_tokens)
} else {
prompt_tokens
};
let input = (uncached as f64 * self.input_per_million
+ cached_input_tokens as f64 * read_rate
+ cache_creation_tokens as f64 * self.input_per_million)
/ TOKENS_PER_MILLION;
let output = completion_tokens as f64 * self.output_per_million / TOKENS_PER_MILLION;
(input, output)
}
}
/// Derive the blended ranking map from the structured rates map.
fn blended_costs(rates: &HashMap<String, ModelRates>) -> HashMap<String, f64> {
rates
.iter()
.map(|(k, r)| (k.clone(), r.blended()))
.collect()
}
pub struct ModelMetricsService {
cost: Arc<RwLock<HashMap<String, f64>>>,
rates: Arc<RwLock<HashMap<String, ModelRates>>>,
latency: Arc<RwLock<HashMap<String, f64>>>,
}
impl ModelMetricsService {
pub async fn new(sources: &[MetricsSource], client: reqwest::Client) -> Self {
let cost_data = Arc::new(RwLock::new(HashMap::new()));
let rates_data = Arc::new(RwLock::new(HashMap::new()));
let latency_data = Arc::new(RwLock::new(HashMap::new()));
for source in sources {
@ -34,10 +105,12 @@ impl ModelMetricsService {
let data = fetch_cost_pricing(&provider, &url, &client, &aliases).await;
info!(models = data.len(), provider = provider_name, url = %url, "fetched cost pricing");
*cost_data.write().await = data;
*cost_data.write().await = blended_costs(&data);
*rates_data.write().await = data;
if let Some(interval_secs) = cfg.refresh_interval {
let cost_clone = Arc::clone(&cost_data);
let rates_clone = Arc::clone(&rates_data);
let client_clone = client.clone();
let interval = Duration::from_secs(interval_secs);
tokio::spawn(async move {
@ -47,7 +120,8 @@ impl ModelMetricsService {
fetch_cost_pricing(&provider, &url, &client_clone, &aliases)
.await;
info!(models = data.len(), provider = provider_name, url = %url, "refreshed cost pricing");
*cost_clone.write().await = data;
*cost_clone.write().await = blended_costs(&data);
*rates_clone.write().await = data;
}
});
}
@ -81,10 +155,34 @@ impl ModelMetricsService {
ModelMetricsService {
cost: cost_data,
rates: rates_data,
latency: latency_data,
}
}
/// Build a service directly from a rates map (no network). Test-only: lets
/// handler/router tests exercise switch-cost math with deterministic pricing.
#[cfg(test)]
pub fn from_rates_for_test(rates: HashMap<String, ModelRates>) -> Self {
ModelMetricsService {
cost: Arc::new(RwLock::new(blended_costs(&rates))),
rates: Arc::new(RwLock::new(rates)),
latency: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Structured per-million rates for a model, if the cost feed published them.
/// Falls back to the bare model id (without `provider/` prefix) like the
/// blended cost map does.
pub async fn model_rates(&self, model: &str) -> Option<ModelRates> {
let rates = self.rates.read().await;
rates.get(model).copied().or_else(|| {
model
.split_once('/')
.and_then(|(_, bare)| rates.get(bare).copied())
})
}
/// Rank `models` by `policy`, returning them in preference order.
/// Models with no metric data are appended at the end in their original order.
pub async fn rank_models(&self, models: &[String], policy: &SelectionPolicy) -> Vec<String> {
@ -167,10 +265,14 @@ struct DoModel {
pricing: Option<DoPricing>,
}
/// DigitalOcean catalog pricing. Despite the `_per_million` field names, the DO
/// API returns these as USD **per token** (e.g. gpt-4o input `2.5e-6`), so they
/// are scaled to per-million at ingestion to match `ModelRates`' convention.
#[derive(serde::Deserialize)]
struct DoPricing {
input_price_per_million: Option<f64>,
output_price_per_million: Option<f64>,
cache_read_input_price_per_million: Option<f64>,
}
#[derive(serde::Deserialize)]
@ -188,6 +290,7 @@ struct ModelsDevModel {
struct ModelsDevCost {
input: Option<f64>,
output: Option<f64>,
cache_read: Option<f64>,
}
fn default_cost_url(provider: &CostProvider) -> &'static str {
@ -209,7 +312,7 @@ async fn fetch_cost_pricing(
url: &str,
client: &reqwest::Client,
aliases: &HashMap<String, String>,
) -> HashMap<String, f64> {
) -> HashMap<String, ModelRates> {
match provider {
CostProvider::Digitalocean => fetch_do_pricing(url, client, aliases).await,
CostProvider::ModelsDev => fetch_models_dev_pricing(url, client, aliases).await,
@ -220,21 +323,10 @@ async fn fetch_do_pricing(
url: &str,
client: &reqwest::Client,
aliases: &HashMap<String, String>,
) -> HashMap<String, f64> {
) -> HashMap<String, ModelRates> {
match client.get(url).send().await {
Ok(resp) => match resp.json::<DoModelList>().await {
Ok(list) => list
.data
.into_iter()
.filter_map(|m| {
let pricing = m.pricing?;
let raw_key = m.model_id.clone();
let key = aliases.get(&raw_key).cloned().unwrap_or(raw_key);
let cost = pricing.input_price_per_million.unwrap_or(0.0)
+ pricing.output_price_per_million.unwrap_or(0.0);
Some((key, cost))
})
.collect(),
Ok(list) => parse_do_pricing(list, aliases),
Err(err) => {
warn!(error = %err, url = %url, "failed to parse digitalocean pricing response");
HashMap::new()
@ -247,16 +339,45 @@ async fn fetch_do_pricing(
}
}
/// Map the DO catalog into `ModelRates`, scaling DO's per-token prices into the
/// per-million convention the rest of the router uses.
fn parse_do_pricing(
list: DoModelList,
aliases: &HashMap<String, String>,
) -> HashMap<String, ModelRates> {
list.data
.into_iter()
.filter_map(|m| {
let pricing = m.pricing?;
let raw_key = m.model_id.clone();
let key = aliases.get(&raw_key).cloned().unwrap_or(raw_key);
// DO reports prices per token; scale to per-million so the
// absolute-USD consumers (the switch-cost gate) are correct.
// Relative ranking via `blended()` is unaffected either way.
let rates = ModelRates {
input_per_million: pricing.input_price_per_million.unwrap_or(0.0)
* TOKENS_PER_MILLION,
output_per_million: pricing.output_price_per_million.unwrap_or(0.0)
* TOKENS_PER_MILLION,
cache_read_per_million: pricing
.cache_read_input_price_per_million
.map(|r| r * TOKENS_PER_MILLION),
};
Some((key, rates))
})
.collect()
}
/// models.dev publishes a top-level object keyed by provider id; each provider
/// carries a `models` map whose keys are `creator/model` ids and whose `cost`
/// block holds per-million USD rates. We sum input + output (mirroring the DO
/// ranking metric) and key the result by `creator/model_id` so it lines up with
/// Plano's `provider/model` routing names.
/// block holds per-million USD rates. We keep the structured rates (including
/// `cache_read` when published) and key the result by `creator/model_id` so it
/// lines up with Plano's `provider/model` routing names.
async fn fetch_models_dev_pricing(
url: &str,
client: &reqwest::Client,
aliases: &HashMap<String, String>,
) -> HashMap<String, f64> {
) -> HashMap<String, ModelRates> {
match client.get(url).send().await {
Ok(resp) => match resp.json::<HashMap<String, ModelsDevProvider>>().await {
Ok(providers) => parse_models_dev_pricing(providers, aliases),
@ -275,7 +396,7 @@ async fn fetch_models_dev_pricing(
fn parse_models_dev_pricing(
providers: HashMap<String, ModelsDevProvider>,
aliases: &HashMap<String, String>,
) -> HashMap<String, f64> {
) -> HashMap<String, ModelRates> {
let mut out = HashMap::new();
for (provider_id, provider) in providers {
for (model_key, model) in provider.models {
@ -286,11 +407,15 @@ fn parse_models_dev_pricing(
// First-party providers use bare model keys (`claude-opus-4-5`),
// so compose `provider/model` to line up with Plano routing names.
let raw_key = format!("{provider_id}/{model_key}");
let total = input + output;
let rates = ModelRates {
input_per_million: input,
output_per_million: output,
cache_read_per_million: cost.cache_read,
};
let key = aliases.get(&raw_key).cloned().unwrap_or(raw_key);
out.insert(key, total);
out.insert(key, rates);
// Also register the bare model id as a fallback lookup.
out.entry(model_key).or_insert(total);
out.entry(model_key).or_insert(rates);
}
}
out
@ -356,6 +481,46 @@ mod tests {
SelectionPolicy { prefer }
}
fn service_with(
cost: HashMap<String, f64>,
latency: HashMap<String, f64>,
) -> ModelMetricsService {
ModelMetricsService {
cost: Arc::new(RwLock::new(cost)),
rates: Arc::new(RwLock::new(HashMap::new())),
latency: Arc::new(RwLock::new(latency)),
}
}
#[test]
fn request_cost_openai_convention_subtracts_cached_from_prompt() {
// input $3/M, output $15/M, cached read $0.30/M. prompt_tokens (=1000) INCLUDES
// the 400 cached tokens → uncached = 600.
let rates = ModelRates {
input_per_million: 3.0,
output_per_million: 15.0,
cache_read_per_million: Some(0.30),
};
let (input, output) = rates.request_cost_usd(1000, 400, 0, 200, true, 0.1);
// 600*3 + 400*0.30 = 1800 + 120 = 1920 micro-$/M → /1e6
assert!((input - (1920.0 / 1_000_000.0)).abs() < 1e-12);
assert!((output - (200.0 * 15.0 / 1_000_000.0)).abs() < 1e-12);
}
#[test]
fn request_cost_anthropic_convention_prices_uncached_and_creation() {
// Anthropic: prompt_tokens (=input_tokens=100) EXCLUDES cached read (30) and
// creation (20). No published cached rate → falls back to input*discount.
let rates = ModelRates {
input_per_million: 3.0,
output_per_million: 15.0,
cache_read_per_million: None,
};
let (input, _output) = rates.request_cost_usd(100, 30, 20, 50, false, 0.1);
// uncached 100*3 + cached 30*(3*0.1=0.3) + creation 20*3 = 300 + 9 + 60 = 369
assert!((input - (369.0 / 1_000_000.0)).abs() < 1e-12);
}
#[test]
fn test_rank_by_ascending_metric_picks_lowest_first() {
let models = vec!["a".to_string(), "b".to_string(), "c".to_string()];
@ -386,15 +551,15 @@ mod tests {
#[tokio::test]
async fn test_rank_models_cheapest() {
let service = ModelMetricsService {
cost: Arc::new(RwLock::new({
let service = service_with(
{
let mut m = HashMap::new();
m.insert("gpt-4o".to_string(), 0.005);
m.insert("gpt-4o-mini".to_string(), 0.0001);
m
})),
latency: Arc::new(RwLock::new(HashMap::new())),
};
},
HashMap::new(),
);
let models = vec!["gpt-4o".to_string(), "gpt-4o-mini".to_string()];
let result = service
.rank_models(&models, &make_policy(SelectionPreference::Cheapest))
@ -404,15 +569,12 @@ mod tests {
#[tokio::test]
async fn test_rank_models_fastest() {
let service = ModelMetricsService {
cost: Arc::new(RwLock::new(HashMap::new())),
latency: Arc::new(RwLock::new({
let mut m = HashMap::new();
m.insert("gpt-4o".to_string(), 200.0);
m.insert("claude-sonnet".to_string(), 120.0);
m
})),
};
let service = service_with(HashMap::new(), {
let mut m = HashMap::new();
m.insert("gpt-4o".to_string(), 200.0);
m.insert("claude-sonnet".to_string(), 120.0);
m
});
let models = vec!["gpt-4o".to_string(), "claude-sonnet".to_string()];
let result = service
.rank_models(&models, &make_policy(SelectionPreference::Fastest))
@ -422,10 +584,7 @@ mod tests {
#[tokio::test]
async fn test_rank_models_fallback_no_metrics() {
let service = ModelMetricsService {
cost: Arc::new(RwLock::new(HashMap::new())),
latency: Arc::new(RwLock::new(HashMap::new())),
};
let service = service_with(HashMap::new(), HashMap::new());
let models = vec!["model-a".to_string(), "model-b".to_string()];
let result = service
.rank_models(&models, &make_policy(SelectionPreference::Cheapest))
@ -435,14 +594,14 @@ mod tests {
#[tokio::test]
async fn test_rank_models_partial_data_appended_last() {
let service = ModelMetricsService {
cost: Arc::new(RwLock::new({
let service = service_with(
{
let mut m = HashMap::new();
m.insert("gpt-4o".to_string(), 0.005);
m
})),
latency: Arc::new(RwLock::new(HashMap::new())),
};
},
HashMap::new(),
);
let models = vec!["gpt-4o-mini".to_string(), "gpt-4o".to_string()];
let result = service
.rank_models(&models, &make_policy(SelectionPreference::Cheapest))
@ -452,15 +611,15 @@ mod tests {
#[tokio::test]
async fn test_rank_models_none_preserves_order() {
let service = ModelMetricsService {
cost: Arc::new(RwLock::new({
let service = service_with(
{
let mut m = HashMap::new();
m.insert("gpt-4o-mini".to_string(), 0.0001);
m.insert("gpt-4o".to_string(), 0.005);
m
})),
latency: Arc::new(RwLock::new(HashMap::new())),
};
},
HashMap::new(),
);
let models = vec!["gpt-4o".to_string(), "gpt-4o-mini".to_string()];
let result = service
.rank_models(&models, &make_policy(SelectionPreference::None))
@ -469,12 +628,61 @@ mod tests {
assert_eq!(result, vec!["gpt-4o", "gpt-4o-mini"]);
}
#[test]
fn test_parse_do_pricing_scales_per_token_to_per_million() {
// DO's catalog returns USD *per token* despite the `_per_million` names.
// These are the real values from the live catalog.
let json = r#"{
"data": [
{
"model_id": "digitalocean/anthropic-claude-4.6-sonnet",
"pricing": {
"input_price_per_million": 3e-6,
"output_price_per_million": 15e-6,
"cache_read_input_price_per_million": 3e-7
}
},
{
"model_id": "digitalocean/openai-gpt-4o",
"pricing": {
"input_price_per_million": 2.5e-6,
"output_price_per_million": 10e-6
}
}
]
}"#;
let list: DoModelList = serde_json::from_str(json).unwrap();
let rates = parse_do_pricing(list, &HashMap::new());
// Scaled to per-million so the switch-cost gate compares real dollars.
let sonnet = rates
.get("digitalocean/anthropic-claude-4.6-sonnet")
.unwrap();
assert!((sonnet.input_per_million - 3.0).abs() < 1e-9);
assert!((sonnet.output_per_million - 15.0).abs() < 1e-9);
// DO *does* publish a cached-read rate — it must be captured, not dropped.
assert_eq!(sonnet.cache_read_per_million, Some(0.3));
let gpt4o = rates.get("digitalocean/openai-gpt-4o").unwrap();
assert!((gpt4o.input_per_million - 2.5).abs() < 1e-9);
assert_eq!(gpt4o.cache_read_per_million, None);
// Regression: a ~5k-token switch off warm sonnet to cold gpt-4o must now
// cost real dollars, not ~1e-8. This is the bug the live gate test caught.
let switch_cost =
5_000.0 / 1_000_000.0 * (gpt4o.input_per_million - sonnet.cached_input_rate(0.1));
assert!(
switch_cost > 0.01,
"expected ~$0.011 of switch cost, got {switch_cost}"
);
}
#[test]
fn test_parse_models_dev_pricing_composes_provider_keys() {
let json = r#"{
"anthropic": {
"models": {
"claude-opus-4-5": {"cost": {"input": 5.0, "output": 25.0}}
"claude-opus-4-5": {"cost": {"input": 5.0, "output": 25.0, "cache_read": 0.5}}
}
},
"groq": {
@ -486,14 +694,21 @@ mod tests {
}"#;
let providers: HashMap<String, ModelsDevProvider> = serde_json::from_str(json).unwrap();
let aliases = HashMap::new();
let prices = parse_models_dev_pricing(providers, &aliases);
let rates = parse_models_dev_pricing(providers, &aliases);
assert_eq!(prices.get("anthropic/claude-opus-4-5"), Some(&30.0));
assert_eq!(prices.get("groq/llama-3.3-70b-versatile"), Some(&1.38));
let opus = rates.get("anthropic/claude-opus-4-5").unwrap();
assert_eq!(opus.blended(), 30.0);
assert_eq!(opus.cache_read_per_million, Some(0.5));
let llama = rates.get("groq/llama-3.3-70b-versatile").unwrap();
assert_eq!(llama.blended(), 1.38);
assert_eq!(llama.cache_read_per_million, None);
// bare fallback also registered
assert_eq!(prices.get("claude-opus-4-5"), Some(&30.0));
assert_eq!(
rates.get("claude-opus-4-5").map(|r| r.blended()),
Some(30.0)
);
// models with no cost block are skipped
assert!(!prices.contains_key("groq/whisper-large-v3-turbo"));
assert!(!rates.contains_key("groq/whisper-large-v3-turbo"));
}
#[test]
@ -507,10 +722,53 @@ mod tests {
"openai/gpt-oss-120b".to_string(),
"openai/gpt-4o".to_string(),
);
let prices = parse_models_dev_pricing(providers, &aliases);
let rates = parse_models_dev_pricing(providers, &aliases);
assert_eq!(prices.get("openai/gpt-4o"), Some(&3.0));
assert!(!prices.contains_key("openai/gpt-oss-120b"));
assert_eq!(rates.get("openai/gpt-4o").map(|r| r.blended()), Some(3.0));
assert!(!rates.contains_key("openai/gpt-oss-120b"));
}
#[test]
fn test_cached_input_rate_prefers_published_rate() {
let with_feed = ModelRates {
input_per_million: 3.0,
output_per_million: 15.0,
cache_read_per_million: Some(0.3),
};
// Published cache_read wins; the discount is ignored.
assert_eq!(with_feed.cached_input_rate(0.5), 0.3);
let without_feed = ModelRates {
input_per_million: 3.0,
output_per_million: 15.0,
cache_read_per_million: None,
};
// Falls back to input * discount.
assert!((without_feed.cached_input_rate(0.1) - 0.3).abs() < 1e-9);
}
#[tokio::test]
async fn test_model_rates_falls_back_to_bare_model_id() {
let service = ModelMetricsService {
cost: Arc::new(RwLock::new(HashMap::new())),
rates: Arc::new(RwLock::new({
let mut m = HashMap::new();
m.insert(
"claude-sonnet-4-5".to_string(),
ModelRates {
input_per_million: 3.0,
output_per_million: 15.0,
cache_read_per_million: Some(0.3),
},
);
m
})),
latency: Arc::new(RwLock::new(HashMap::new())),
};
// provider-prefixed lookup falls back to the bare id
let rates = service.model_rates("vercel/claude-sonnet-4-5").await;
assert_eq!(rates.map(|r| r.input_per_million), Some(3.0));
assert!(service.model_rates("unknown/model").await.is_none());
}
#[test]

View file

@ -20,9 +20,41 @@ use crate::metrics::labels as metric_labels;
use crate::router::orchestrator_model_v1;
use crate::session_cache::SessionCache;
pub use crate::session_cache::CachedRoute;
pub use crate::session_cache::SessionBinding;
const DEFAULT_SESSION_TTL_SECONDS: u64 = 600;
const TOKENS_PER_MILLION: f64 = 1_000_000.0;
/// Input-cost of moving a session off its anchor onto `candidate`.
///
/// Staying re-reads the whole context at the anchor's read rate (`anchor_read_rate` — the
/// cached rate when prompt caching keeps the anchor warm, the plain uncached rate when it
/// doesn't). The candidate re-reads `candidate_warm_tokens` (whatever it still has cached
/// from an earlier visit this session) at *its* cached rate, and the remaining,
/// freshly-appended tokens at its uncached rate. When the candidate is cold
/// (`candidate_warm_tokens == 0`) this reduces to the whole context at the uncached rate —
/// i.e. a first-time switch. Crediting warm tokens is what makes an A→B→A return cheap:
/// on the way back A still holds most of the context, so only the delta is re-ingested.
///
/// Deliberately input-only: output-token savings are unknowable before the response is
/// generated (reasoning models can emit 5-10x the tokens for the same task), so they are
/// never credited. Rates are USD per million tokens. Negative when the switch is outright
/// cheaper than staying; the caller draws a positive cost down from the overhead cap.
pub fn switch_cost_in_usd(
context_tokens: u64,
candidate_warm_tokens: u64,
anchor_read_rate: f64,
candidate_uncached_rate: f64,
candidate_cached_rate: f64,
) -> f64 {
let warm = candidate_warm_tokens.min(context_tokens);
let fresh = context_tokens - warm;
let candidate_cost = (fresh as f64 * candidate_uncached_rate
+ warm as f64 * candidate_cached_rate)
/ TOKENS_PER_MILLION;
let anchor_cost = context_tokens as f64 * anchor_read_rate / TOKENS_PER_MILLION;
candidate_cost - anchor_cost
}
pub struct OrchestratorService {
orchestrator_url: String,
@ -126,43 +158,126 @@ impl OrchestratorService {
}
}
pub async fn get_cached_route(
/// Look up a session binding. Warmth is the caller's concern (time since
/// `last_used`); this only reports whether a binding exists.
pub async fn get_binding(
&self,
session_id: &str,
tenant_id: Option<&str>,
) -> Option<CachedRoute> {
) -> Option<SessionBinding> {
let cache = self.session_cache.as_ref()?;
let result = cache.get(&Self::session_key(tenant_id, session_id)).await;
bs_metrics::record_session_cache_event(if result.is_some() {
metric_labels::SESSION_CACHE_HIT
} else {
metric_labels::SESSION_CACHE_MISS
bs_metrics::record_session_cache_event(match result {
Some(_) => metric_labels::SESSION_CACHE_HIT,
None => metric_labels::SESSION_CACHE_MISS,
});
result
}
pub async fn cache_route(
/// The GC bound for a session binding: the per-scope override when provided,
/// otherwise the global `routing.session_ttl_seconds`. This only governs when an
/// idle binding is reclaimed from memory — not whether its cache is warm.
pub fn effective_session_ttl(&self, ttl_override_seconds: Option<u64>) -> Duration {
ttl_override_seconds
.map(Duration::from_secs)
.unwrap_or(self.session_ttl)
}
/// Persist a session binding with a GC bound (defaults to `routing.session_ttl_seconds`
/// when `gc_ttl` is `None`).
pub async fn store_binding(
&self,
session_id: String,
session_id: &str,
tenant_id: Option<&str>,
model_name: String,
route_name: Option<String>,
binding: SessionBinding,
gc_ttl: Option<Duration>,
) {
if let Some(ref cache) = self.session_cache {
cache
.put(
&Self::session_key(tenant_id, &session_id),
CachedRoute {
model_name,
route_name,
},
self.session_ttl,
&Self::session_key(tenant_id, session_id),
binding,
gc_ttl.unwrap_or(self.session_ttl),
)
.await;
bs_metrics::record_session_cache_event(metric_labels::SESSION_CACHE_STORE);
}
}
/// Structured per-million pricing for a model, from the configured cost feed.
/// `None` when no cost source is configured or the model is unknown to the feed.
pub async fn model_rates(&self, model: &str) -> Option<super::model_metrics::ModelRates> {
self.metrics_service.as_ref()?.model_rates(model).await
}
/// 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`.
///
/// 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
/// veto the router on guesswork. `cache_read_discount` estimates a model's cached-read
/// rate when the feed doesn't publish one. Negative when the switch is outright cheaper.
pub async fn estimate_switch_cost_in_usd(
&self,
context_tokens: u64,
anchor_model: &str,
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,
candidate.input_per_million,
candidate.cached_input_rate(cache_read_discount),
))
}
/// 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).
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)
}
// ---- LLM routing ----
pub async fn determine_route(
@ -348,86 +463,190 @@ mod tests {
)
}
#[tokio::test]
async fn test_cache_miss_returns_none() {
let svc = make_orchestrator_service(600, 100);
assert!(svc
.get_cached_route("unknown-session", None)
.await
.is_none());
fn binding(model: &str, route_name: Option<&str>) -> SessionBinding {
SessionBinding {
anchor_model: model.to_string(),
default_model: model.to_string(),
route_name: route_name.map(|r| r.to_string()),
prefix_hash: None,
last_used: std::time::SystemTime::now(),
cached_tokens: 0,
baseline_usd: 0.0,
switch_spend_usd: 0.0,
switches: 0,
session_cost_usd: 0.0,
history: Vec::new(),
}
}
#[tokio::test]
async fn test_cache_hit_returns_cached_route() {
async fn test_cache_miss_returns_none() {
let svc = make_orchestrator_service(600, 100);
svc.cache_route(
"s1".to_string(),
None,
"gpt-4o".to_string(),
Some("code".to_string()),
)
.await;
assert!(svc.get_binding("unknown-session", None).await.is_none());
}
let cached = svc.get_cached_route("s1", None).await.unwrap();
assert_eq!(cached.model_name, "gpt-4o");
#[tokio::test]
async fn test_cache_hit_returns_binding() {
let svc = make_orchestrator_service(600, 100);
svc.store_binding("s1", None, binding("gpt-4o", Some("code")), None)
.await;
let cached = svc.get_binding("s1", None).await.unwrap();
assert_eq!(cached.anchor_model, "gpt-4o");
assert_eq!(cached.route_name, Some("code".to_string()));
}
#[tokio::test]
async fn test_cache_expired_entry_returns_none() {
let svc = make_orchestrator_service(0, 100);
svc.cache_route("s1".to_string(), None, "gpt-4o".to_string(), None)
svc.store_binding("s1", None, binding("gpt-4o", None), None)
.await;
assert!(svc.get_cached_route("s1", None).await.is_none());
assert!(svc.get_binding("s1", None).await.is_none());
}
#[tokio::test]
async fn test_expired_entries_not_returned() {
let svc = make_orchestrator_service(0, 100);
svc.cache_route("s1".to_string(), None, "gpt-4o".to_string(), None)
svc.store_binding("s1", None, binding("gpt-4o", None), None)
.await;
svc.cache_route("s2".to_string(), None, "claude".to_string(), None)
svc.store_binding("s2", None, binding("claude", None), None)
.await;
assert!(svc.get_cached_route("s1", None).await.is_none());
assert!(svc.get_cached_route("s2", None).await.is_none());
assert!(svc.get_binding("s1", None).await.is_none());
assert!(svc.get_binding("s2", None).await.is_none());
}
#[tokio::test]
async fn test_cache_evicts_oldest_when_full() {
let svc = make_orchestrator_service(600, 2);
svc.cache_route("s1".to_string(), None, "model-a".to_string(), None)
svc.store_binding("s1", None, binding("model-a", None), None)
.await;
tokio::time::sleep(Duration::from_millis(10)).await;
svc.cache_route("s2".to_string(), None, "model-b".to_string(), None)
svc.store_binding("s2", None, binding("model-b", None), None)
.await;
svc.cache_route("s3".to_string(), None, "model-c".to_string(), None)
svc.store_binding("s3", None, binding("model-c", None), None)
.await;
assert!(svc.get_cached_route("s1", None).await.is_none());
assert!(svc.get_cached_route("s2", None).await.is_some());
assert!(svc.get_cached_route("s3", None).await.is_some());
assert!(svc.get_binding("s1", None).await.is_none());
assert!(svc.get_binding("s2", None).await.is_some());
assert!(svc.get_binding("s3", None).await.is_some());
}
#[tokio::test]
async fn test_cache_update_existing_session_does_not_evict() {
let svc = make_orchestrator_service(600, 2);
svc.cache_route("s1".to_string(), None, "model-a".to_string(), None)
svc.store_binding("s1", None, binding("model-a", None), None)
.await;
svc.cache_route("s2".to_string(), None, "model-b".to_string(), None)
svc.store_binding("s2", None, binding("model-b", None), None)
.await;
svc.cache_route(
"s1".to_string(),
svc.store_binding("s1", None, binding("model-a-updated", Some("route")), None)
.await;
let s1 = svc.get_binding("s1", None).await.unwrap();
assert_eq!(s1.anchor_model, "model-a-updated");
assert!(svc.get_binding("s2", None).await.is_some());
}
#[tokio::test]
async fn test_gc_ttl_override_extends_binding_lifetime() {
// Global GC bound of 0 would reclaim immediately; the per-call override keeps it.
let svc = make_orchestrator_service(0, 100);
svc.store_binding(
"s1",
None,
"model-a-updated".to_string(),
Some("route".to_string()),
binding("gpt-4o", None),
Some(Duration::from_secs(600)),
)
.await;
let cached = svc.get_binding("s1", None).await.unwrap();
assert_eq!(cached.anchor_model, "gpt-4o");
}
let s1 = svc.get_cached_route("s1", None).await.unwrap();
assert_eq!(s1.model_name, "model-a-updated");
assert!(svc.get_cached_route("s2", None).await.is_some());
#[tokio::test]
async fn test_binding_fields_round_trip_through_cache() {
let svc = make_orchestrator_service(600, 100);
let mut b = binding("gpt-4o", None);
b.prefix_hash = Some(0xdead_beef);
b.cached_tokens = 12_345;
b.baseline_usd = 1.5;
b.switch_spend_usd = 0.42;
b.switches = 3;
b.session_cost_usd = 2.75;
svc.store_binding("s1", None, b, None).await;
let cached = svc.get_binding("s1", None).await.unwrap();
assert_eq!(cached.prefix_hash, Some(0xdead_beef));
assert_eq!(cached.cached_tokens, 12_345);
assert!((cached.baseline_usd - 1.5).abs() < 1e-9);
assert!((cached.switch_spend_usd - 0.42).abs() < 1e-9);
assert_eq!(cached.switches, 3);
assert!((cached.session_cost_usd - 2.75).abs() < 1e-9);
}
// ---- switch-cost math ----
//
// Real models.dev rates (USD per million input tokens):
// claude-opus-4-1: input 15, cache_read 1.5
// claude-sonnet-4-5: input 3, cache_read 0.3
// claude-haiku-4-5: input 1, cache_read 0.1
// gpt-4.1: input 2, cache_read 0.5
#[test]
fn negative_cost_when_candidate_undercuts_cached_rate() {
// Anchor opus (cached 1.5) -> haiku (uncached 1.0) over 100k context, cold
// candidate: cost = 0.1M x (1.0 - 1.5) = -$0.05 — cheaper even after re-reading.
let cost = switch_cost_in_usd(100_000, 0, 1.5, 1.0, 0.1);
assert!((cost - (-0.05)).abs() < 1e-9);
}
#[test]
fn positive_cost_when_candidate_pricier_than_cached_rate() {
// Anchor opus (cached 1.5) -> gpt-4.1 (uncached 2.0) over 100k, cold candidate:
// cost = 0.1M x (2.0 - 1.5) = +$0.05.
let cost = switch_cost_in_usd(100_000, 0, 1.5, 2.0, 0.5);
assert!((cost - 0.05).abs() < 1e-9);
}
#[test]
fn large_context_amplifies_cost() {
// Anchor sonnet (cached 0.3) -> gpt-5.5-class (uncached 5.0) over 150k, cold:
// cost = 0.15M x (5.0 - 0.3) = +$0.705.
let cost = switch_cost_in_usd(150_000, 0, 0.3, 5.0, 0.5);
assert!((cost - 0.705).abs() < 1e-9);
}
#[test]
fn cost_scales_linearly_with_context() {
let small = switch_cost_in_usd(10_000, 0, 0.3, 0.8, 0.1);
let large = switch_cost_in_usd(1_000_000, 0, 0.3, 0.8, 0.1);
assert!((large / small - 100.0).abs() < 1e-6);
}
#[test]
fn tiny_context_cost_is_negligible() {
// 2k-token chat: even an expensive candidate costs ~$0.009.
let cost = switch_cost_in_usd(2_000, 0, 0.3, 5.0, 0.5);
assert!(cost < 0.01);
}
#[test]
fn warm_return_charges_only_the_delta() {
// Anchor sonnet (cached 0.3). Candidate gpt-4.1 (uncached 2.0, cached 0.5) is
// still warm from an earlier visit holding 90k of the 100k context. Only the
// 10k fresh tokens re-read at 2.0; the 90k warm tokens re-read at 0.5:
// candidate = (10k x 2.0 + 90k x 0.5)/1M = 0.020 + 0.045 = $0.065
// anchor = 100k x 0.3 /1M = $0.030
// switch = 0.065 - 0.030 = +$0.035
let warm = switch_cost_in_usd(100_000, 90_000, 0.3, 2.0, 0.5);
assert!(
(warm - 0.035).abs() < 1e-9,
"warm-return cost {warm} != 0.035"
);
// Cold, same switch re-reads the whole 100k at 2.0: (100k x 2.0)/1M - 0.03 = $0.17.
let cold = switch_cost_in_usd(100_000, 0, 0.3, 2.0, 0.5);
assert!((cold - 0.17).abs() < 1e-9, "cold cost {cold} != 0.17");
assert!(warm < cold, "returning to a warm model must be cheaper");
}
}

View file

@ -9,9 +9,9 @@ use lru::LruCache;
use tokio::sync::Mutex;
use tracing::info;
use super::{CachedRoute, SessionCache};
use super::{SessionBinding, SessionCache};
type CacheStore = Mutex<LruCache<String, (CachedRoute, Instant, Duration)>>;
type CacheStore = Mutex<LruCache<String, (SessionBinding, Instant, Duration)>>;
pub struct MemorySessionCache {
store: Arc<CacheStore>,
@ -38,6 +38,8 @@ impl MemorySessionCache {
async fn evict_expired(store: &CacheStore) {
let mut cache = store.lock().await;
// The TTL is only a GC bound: drop bindings once they've outlived the window
// in which they could plausibly still be warm.
let expired: Vec<String> = cache
.iter()
.filter(|(_, (_, inserted_at, ttl))| inserted_at.elapsed() >= *ttl)
@ -59,21 +61,21 @@ impl MemorySessionCache {
#[async_trait]
impl SessionCache for MemorySessionCache {
async fn get(&self, key: &str) -> Option<CachedRoute> {
async fn get(&self, key: &str) -> Option<SessionBinding> {
let mut cache = self.store.lock().await;
if let Some((route, inserted_at, ttl)) = cache.get(key) {
if let Some((binding, inserted_at, ttl)) = cache.get(key) {
if inserted_at.elapsed() < *ttl {
return Some(route.clone());
return Some(binding.clone());
}
}
None
}
async fn put(&self, key: &str, route: CachedRoute, ttl: Duration) {
async fn put(&self, key: &str, binding: SessionBinding, ttl: Duration) {
self.store
.lock()
.await
.put(key.to_string(), (route, Instant::now(), ttl));
.put(key.to_string(), (binding, Instant::now(), ttl));
}
async fn remove(&self, key: &str) {

View file

@ -1,4 +1,5 @@
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use common::configuration::Configuration;
@ -8,21 +9,155 @@ use tracing::{debug, info};
pub mod memory;
pub mod redis;
/// A conversation's binding to a model, plus the state the session router needs to
/// reason about cache warmth and switch affordability across turns.
///
/// Warmth is no longer derived from the cache's own expiry — the entry is kept alive
/// as a plain KV value (subject only to a GC bound) and the router decides warmth from
/// [`SessionBinding::last_used`] against the provider's cache window. This is what lets
/// the decision path reason about warmth without ever seeing a provider response.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct CachedRoute {
pub model_name: String,
pub struct SessionBinding {
/// Provider-qualified model that handled the latest request (e.g. `openai/gpt-4o`).
/// This is what the session is currently *warm on*; a proposed switch's cost is
/// measured against reading the context at this model's cached rate. It tracks the
/// last dispatched model and changes whenever a switch is honored.
pub anchor_model: String,
/// Provider-qualified model the session started on this warm episode — the model it
/// would have stayed on had it *never switched*. The never-switch baseline is priced
/// against this (not `anchor_model`, which drifts as switches happen). Set when a warm
/// episode begins and preserved across its turns.
#[serde(default)]
pub default_model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route_name: Option<String>,
/// Hash of the stable prompt prefix (system + tools) observed when the binding was
/// stored. Used to detect prefix drift: if a later request's prefix hash differs,
/// the provider cache is already lost so a switch is free.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix_hash: Option<u64>,
/// When this session was last dispatched. Warmth = `now - last_used` compared
/// against the provider's idle/hard cache window.
#[serde(default = "SystemTime::now", with = "epoch_secs")]
pub last_used: SystemTime,
/// Best estimate of the cacheable context size (input tokens) — the tokens a switch
/// would have to re-ingest at the uncached rate. Refined from real usage on the
/// full-proxy path; the tokenizer estimate on the decision path.
#[serde(default)]
pub cached_tokens: u64,
/// Cumulative *never-switch* baseline (USD) for this warm episode: the running cost
/// the session would have paid by staying on its `default_model`. Grows each warm
/// turn. This is the denominator the percentage overhead cap is measured against.
#[serde(default)]
pub baseline_usd: f64,
/// Cumulative overhead (USD) actually spent on paid switches this warm episode.
/// Monotonic: paid switches add to it, free/cheaper switches never subtract. A paid
/// switch is allowed only while `switch_spend_usd + cost <= pct * baseline_usd`.
#[serde(default)]
pub switch_spend_usd: f64,
/// Number of model switches taken during this warm session (observability).
#[serde(default)]
pub switches: u32,
/// Bounded most-recent-first-ish record of the models this session has been
/// dispatched to, each with when it was last used and how large the context was
/// then. Lets the switch-cost estimate credit a *return* to a still-warm model (it
/// re-reads only the tokens appended since, not the whole context) and gives future
/// routing policies per-model recency to reason about. Capped at
/// [`MAX_ROUTE_HISTORY`] distinct models (LRU-evicted).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<RouteVisit>,
/// Cumulative *actual* cost (USD, input + output) of the whole conversation, priced
/// from the configured catalog rates and refined from real usage each turn on the
/// full-proxy path. Conversation-level (not per warm episode): it persists across
/// cold re-binds and is best-effort (resets only if the binding is evicted).
#[serde(default)]
pub session_cost_usd: f64,
}
/// One model this session has been dispatched to, with the recency and context size
/// needed to estimate whether its provider cache is still warm on a later return.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct RouteVisit {
/// Provider-qualified model id (e.g. `openai/gpt-4o`).
pub model: String,
/// When this model last handled a turn in this session.
#[serde(with = "epoch_secs")]
pub last_used: SystemTime,
/// Context size (input tokens) that model saw on its last turn — the prefix it may
/// still have cached if it's revisited before its cache window elapses.
#[serde(default)]
pub cached_tokens: u64,
}
/// Max distinct models retained in [`SessionBinding::history`]. Small: real sessions
/// touch a handful of models, and the entry has to stay compact on the Redis wire.
pub const MAX_ROUTE_HISTORY: usize = 8;
/// Record (or refresh) a model visit in `history`, then LRU-evict down to
/// [`MAX_ROUTE_HISTORY`]. Refreshing an existing model updates its recency and context
/// size in place rather than appending a duplicate.
pub fn record_route_visit(
history: &mut Vec<RouteVisit>,
model: &str,
last_used: SystemTime,
cached_tokens: u64,
) {
if let Some(entry) = history.iter_mut().find(|e| e.model == model) {
entry.last_used = last_used;
entry.cached_tokens = cached_tokens;
} else {
history.push(RouteVisit {
model: model.to_string(),
last_used,
cached_tokens,
});
}
while history.len() > MAX_ROUTE_HISTORY {
if let Some(idx) = history
.iter()
.enumerate()
.min_by_key(|(_, e)| e.last_used)
.map(|(i, _)| i)
{
history.remove(idx);
} else {
break;
}
}
}
/// Serde helper: persist `SystemTime` as whole epoch seconds so the Redis wire format
/// is stable and compact (the default `SystemTime` representation is version-fragile).
mod epoch_secs {
use super::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(t: &SystemTime, s: S) -> Result<S::Ok, S::Error> {
let secs = t
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
s.serialize_u64(secs)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SystemTime, D::Error> {
let secs = u64::deserialize(d)?;
Ok(UNIX_EPOCH + Duration::from_secs(secs))
}
}
#[async_trait]
pub trait SessionCache: Send + Sync {
/// Look up a cached routing decision by key.
async fn get(&self, key: &str) -> Option<CachedRoute>;
/// Look up a session binding by key. `None` when absent or GC-evicted. Warmth is
/// the caller's concern (time since `last_used`), not the cache's.
async fn get(&self, key: &str) -> Option<SessionBinding>;
/// Store a routing decision in the session cache with the given TTL.
async fn put(&self, key: &str, route: CachedRoute, ttl: Duration);
/// Store a session binding with the given GC TTL. The TTL is only a memory bound
/// (keep the entry around at least as long as it could plausibly be warm); it does
/// not define warmth.
async fn put(&self, key: &str, binding: SessionBinding, ttl: Duration);
/// Remove a cached routing decision by key.
/// Remove a session binding by key.
async fn remove(&self, key: &str);
}

View file

@ -4,7 +4,7 @@ use async_trait::async_trait;
use redis::aio::MultiplexedConnection;
use redis::AsyncCommands;
use super::{CachedRoute, SessionCache};
use super::{SessionBinding, SessionCache};
const KEY_PREFIX: &str = "plano:affinity:";
@ -26,18 +26,20 @@ impl RedisSessionCache {
#[async_trait]
impl SessionCache for RedisSessionCache {
async fn get(&self, key: &str) -> Option<CachedRoute> {
async fn get(&self, key: &str) -> Option<SessionBinding> {
let mut conn = self.conn.clone();
let value: Option<String> = conn.get(Self::make_key(key)).await.ok()?;
value.and_then(|v| serde_json::from_str(&v).ok())
}
async fn put(&self, key: &str, route: CachedRoute, ttl: Duration) {
async fn put(&self, key: &str, binding: SessionBinding, ttl: Duration) {
let mut conn = self.conn.clone();
let Ok(json) = serde_json::to_string(&route) else {
// The Redis TTL is only a GC bound; warmth is decided by the router from
// `binding.last_used`, not by expiry here.
let ttl_secs = ttl.as_secs().max(1);
let Ok(json) = serde_json::to_string(&binding) else {
return;
};
let ttl_secs = ttl.as_secs().max(1);
let _: Result<(), _> = conn.set_ex(Self::make_key(key), json, ttl_secs).await;
}

View file

@ -22,10 +22,15 @@ const STREAM_BUFFER_SIZE: usize = 16;
const USAGE_BUFFER_MAX: usize = 2 * 1024 * 1024;
use crate::metrics as bs_metrics;
use crate::metrics::labels as metric_labels;
use crate::router::model_metrics::ModelRates;
use crate::router::orchestrator::OrchestratorService;
use crate::session_cache::{record_route_visit, RouteVisit, SessionBinding};
use crate::signals::otel::emit_signals_to_span;
use crate::signals::{SignalAnalyzer, FLAG_MARKER};
use crate::tracing::{llm, set_service_name};
use hermesllm::apis::openai::Message;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
/// Parsed usage + resolved-model details from a provider response.
#[derive(Debug, Default, Clone)]
@ -39,6 +44,11 @@ struct ExtractedUsage {
/// The model the upstream actually used. For router aliases (e.g.
/// `router:software-engineering`), this differs from the request model.
resolved_model: Option<String>,
/// Provider convention for `prompt_tokens`: OpenAI-shape usage folds cached tokens
/// *into* `prompt_tokens` (so uncached = prompt - cached), while Anthropic-shape
/// reports uncached `input_tokens` separately from `cache_read`/`cache_creation`.
/// Set at parse time from which field `prompt_tokens` was sourced. Drives cost math.
prompt_includes_cached: bool,
}
impl ExtractedUsage {
@ -57,8 +67,9 @@ impl ExtractedUsage {
}
}
if let Some(u) = value.get("usage") {
// OpenAI-shape usage
// OpenAI-shape usage: `prompt_tokens` includes the cached subset.
out.prompt_tokens = u.get("prompt_tokens").and_then(|v| v.as_i64());
out.prompt_includes_cached = out.prompt_tokens.is_some();
out.completion_tokens = u.get("completion_tokens").and_then(|v| v.as_i64());
out.total_tokens = u.get("total_tokens").and_then(|v| v.as_i64());
out.cached_input_tokens = u
@ -187,6 +198,44 @@ pub struct LlmMetricsCtx {
pub upstream_status: u16,
}
/// Response-side session-update context: refreshes the session binding once the real
/// response is in hand, so `last_used` and the context-size estimate (`cached_tokens`)
/// reflect the turn that just completed. The routing decision (model, budget, switches)
/// was already made and persisted on the request side by [`super::handlers::llm::session_router`];
/// this only refines the fields that need the response.
pub struct SessionUpdateCtx {
pub orchestrator: Arc<OrchestratorService>,
pub session_id: String,
pub tenant_id: Option<String>,
/// Provider-qualified model this request actually ran on (the router's final pick).
pub anchor_model: String,
/// The session's never-switch model for this episode — preserved across the refresh.
pub default_model: String,
pub route_name: Option<String>,
pub prefix_hash: Option<u64>,
/// Cumulative never-switch baseline from the decision — preserved across the refresh.
pub baseline_usd: f64,
/// Cumulative switch spend from the decision — preserved across the refresh.
pub switch_spend_usd: f64,
/// Cumulative switch count from the routing decision — preserved across the refresh.
pub switches: u32,
/// Per-model route history from the decision — preserved across the refresh, with
/// the anchor's entry refined to the real prompt-token count.
pub history: Vec<RouteVisit>,
/// Cumulative actual conversation cost (USD) through prior turns. This turn's real
/// cost is added on top once usage is known.
pub session_cost_usd: f64,
/// Catalog rates for the dispatched model, resolved request-side (the response path
/// is synchronous). `None` when no cost source is configured → no cost is computed.
pub cost_rates: Option<ModelRates>,
/// Cached-read discount used to price cached input when the feed omits a cached rate.
pub cache_read_discount: f64,
/// Context-size count to fall back to when the response carries no usage block.
pub context_tokens: u64,
/// GC bound to store the refreshed binding with.
pub gc_ttl: Duration,
}
/// A processor that tracks streaming metrics
pub struct ObservableStreamProcessor {
service_name: String,
@ -201,6 +250,7 @@ pub struct ObservableStreamProcessor {
/// from the buffer (they still pass through to the client).
response_buffer: Vec<u8>,
llm_metrics: Option<LlmMetricsCtx>,
session_update: Option<SessionUpdateCtx>,
metrics_recorded: bool,
}
@ -237,6 +287,7 @@ impl ObservableStreamProcessor {
messages,
response_buffer: Vec::new(),
llm_metrics: None,
session_update: None,
metrics_recorded: false,
}
}
@ -247,6 +298,103 @@ impl ObservableStreamProcessor {
self.llm_metrics = Some(ctx);
self
}
/// Attach session-update context so the processor refreshes `last_used` and the
/// context-size estimate from the real response once usage is known.
pub fn with_session_update(mut self, ctx: SessionUpdateCtx) -> Self {
self.session_update = Some(ctx);
self
}
/// Refresh the session binding from the response so warmth (`last_used`) and the
/// context-size estimate (`cached_tokens`) reflect the completed turn.
fn handle_session_update(&mut self, usage: &ExtractedUsage) {
let Some(update) = self.session_update.take() else {
return;
};
let SessionUpdateCtx {
orchestrator,
session_id,
tenant_id,
anchor_model,
default_model,
route_name,
prefix_hash,
baseline_usd,
switch_spend_usd,
switches,
mut history,
session_cost_usd,
cost_rates,
cache_read_discount,
context_tokens,
gc_ttl,
} = update;
// Prefer the real prompt-token count (the tokens a future switch would re-read)
// over the request-side estimate; fall back to the estimate when absent.
let cached_tokens = usage
.prompt_tokens
.filter(|&p| p > 0)
.map(|p| p as u64)
.unwrap_or(context_tokens);
// Refine the anchor's route-history entry with the real context size, so a later
// return to this model prices its warm portion off actual usage.
record_route_visit(
&mut history,
&anchor_model,
SystemTime::now(),
cached_tokens,
);
// Price this turn from the catalog rates and roll it into the conversation
// total. Emit per-request cost on the (llm) span and carry the running total
// into the binding so the next turn's routing span can surface it.
let session_cost_usd = if let Some(rates) = cost_rates {
let (input_cost, output_cost) = rates.request_cost_usd(
usage.prompt_tokens.unwrap_or(0).max(0) as u64,
usage.cached_input_tokens.unwrap_or(0).max(0) as u64,
usage.cache_creation_tokens.unwrap_or(0).max(0) as u64,
usage.completion_tokens.unwrap_or(0).max(0) as u64,
usage.prompt_includes_cached,
cache_read_discount,
);
let span = tracing::Span::current();
let otel_span = span.context();
let otel_span = otel_span.span();
otel_span.set_attribute(KeyValue::new(llm::INPUT_COST_IN_USD, input_cost));
otel_span.set_attribute(KeyValue::new(llm::OUTPUT_COST_IN_USD, output_cost));
otel_span.set_attribute(KeyValue::new(
llm::TOTAL_COST_IN_USD,
input_cost + output_cost,
));
session_cost_usd + input_cost + output_cost
} else {
session_cost_usd
};
bs_metrics::record_session_binding_event(metric_labels::BINDING_EVENT_REFRESH);
let binding = SessionBinding {
anchor_model,
default_model,
route_name,
prefix_hash,
last_used: SystemTime::now(),
cached_tokens,
baseline_usd,
switch_spend_usd,
switches,
session_cost_usd,
history,
};
// Fire-and-forget: binding bookkeeping must not delay stream completion.
tokio::spawn(async move {
orchestrator
.store_binding(&session_id, tenant_id.as_deref(), binding, Some(gc_ttl))
.await;
});
}
}
impl StreamProcessor for ObservableStreamProcessor {
@ -357,11 +505,47 @@ impl StreamProcessor for ObservableStreamProcessor {
v.max(0) as u64,
);
}
// Prompt-cache token counters + hit/miss baseline (cache-blindness is
// invisible without these: a reroute that burns a warm cache shows up
// here as a miss with zero cache_read tokens).
if let Some(v) = usage.cached_input_tokens {
bs_metrics::record_llm_tokens(
&ctx.provider,
&ctx.model,
metric_labels::TOKEN_KIND_CACHE_READ,
v.max(0) as u64,
);
}
if let Some(v) = usage.cache_creation_tokens {
bs_metrics::record_llm_tokens(
&ctx.provider,
&ctx.model,
metric_labels::TOKEN_KIND_CACHE_WRITE,
v.max(0) as u64,
);
}
if usage.prompt_tokens.is_some() {
let cache_active = usage.cached_input_tokens.unwrap_or(0) > 0
|| usage.cache_creation_tokens.unwrap_or(0) > 0;
bs_metrics::record_prompt_cache_outcome(
&ctx.provider,
&ctx.model,
if cache_active {
metric_labels::PROMPT_CACHE_HIT
} else {
metric_labels::PROMPT_CACHE_MISS
},
);
}
if usage.prompt_tokens.is_none() && usage.completion_tokens.is_none() {
bs_metrics::record_llm_tokens_usage_missing(&ctx.provider, &ctx.model);
}
self.metrics_recorded = true;
}
// Session-binding refresh: update `last_used` + the context-size estimate from
// the completed turn (the routing decision itself was made request-side).
self.handle_session_update(&usage);
// Release the buffered bytes early; nothing downstream needs them.
self.response_buffer.clear();
self.response_buffer.shrink_to_fit();
@ -612,6 +796,8 @@ mod usage_extraction_tests {
assert_eq!(u.total_tokens, Some(46));
assert_eq!(u.cached_input_tokens, Some(5));
assert_eq!(u.reasoning_tokens, None);
// OpenAI folds cached tokens into `prompt_tokens`.
assert!(u.prompt_includes_cached);
}
#[test]
@ -623,6 +809,8 @@ mod usage_extraction_tests {
assert_eq!(u.total_tokens, Some(150));
assert_eq!(u.cached_input_tokens, Some(30));
assert_eq!(u.cache_creation_tokens, Some(20));
// Anthropic reports uncached `input_tokens` separately from cached reads.
assert!(!u.prompt_includes_cached);
}
#[test]

View file

@ -92,6 +92,18 @@ pub mod llm {
/// (OpenAI `completion_tokens_details.reasoning_tokens`, Google `thoughts_token_count`)
pub const REASONING_TOKENS: &str = "llm.usage.reasoning_tokens";
/// This request's input-token cost (USD), priced from the catalog rates: uncached
/// input at the input rate, cached reads at the cached rate, cache creation at the
/// plain input rate. Present only when a cost source is configured.
pub const INPUT_COST_IN_USD: &str = "llm.usage.input_cost_usd";
/// This request's output-token cost (USD) = completion tokens x output rate.
pub const OUTPUT_COST_IN_USD: &str = "llm.usage.output_cost_usd";
/// This request's total cost (USD) = input + output. Sum across a session's turns
/// (group by `plano.session_id`) for the conversation total.
pub const TOTAL_COST_IN_USD: &str = "llm.usage.total_cost_usd";
/// Temperature parameter used
pub const TEMPERATURE: &str = "llm.temperature";
@ -150,6 +162,61 @@ pub mod plano {
/// fields (e.g. PostHog). Sourced from the configured
/// `tracing.exporters[].distinct_id_header`. Absent for anonymous calls.
pub const DISTINCT_ID: &str = "plano.distinct_id";
/// Whether the session's provider cache was inferred warm at decision time
/// (from the idle gap vs. the provider's cache window).
pub const CACHE_WARM: &str = "plano.cache.warm";
/// How long (ms) since the session was last used — the idle gap warmth is measured
/// against.
pub const CACHE_IDLE_MS: &str = "plano.cache.idle_ms";
/// Cumulative switching overhead consumed this session, as a percentage of the
/// never-switch baseline (`100 * switch_spend / baseline`). Directly comparable to
/// the configured `routing.routing_budget.max_overhead_pct`.
pub const SESSION_OVERHEAD_PCT: &str = "plano.session.overhead_pct";
/// Cumulative overhead (USD) actually spent on paid switches this session — the
/// numerator behind `plano.session.overhead_pct`.
pub const SESSION_SWITCH_SPEND_IN_USD: &str = "plano.session.switch_spend_in_usd";
/// Cumulative never-switch baseline (USD) — what staying on the anchor would have
/// cost so far. The denominator behind `plano.session.overhead_pct`.
pub const SESSION_BASELINE_IN_USD: &str = "plano.session.baseline_in_usd";
/// Cumulative number of model switches taken during this warm session.
pub const SESSION_SWITCHES: &str = "plano.session.switches";
/// Cumulative *actual* cost (USD, input + output) of the whole conversation, priced
/// from the configured catalog rates and refined from real usage each turn. Emitted
/// on the routing span; reflects cost through the previous turn (this turn isn't
/// billed yet at decision time). Full-proxy path only.
pub const SESSION_TOTAL_COST_IN_USD: &str = "plano.session.total_cost_in_usd";
/// Actual input-cost (USD) of the proposed model switch — computed from input-token
/// pricing only, output-token cost deliberately excluded. Negative when the candidate
/// is outright cheaper than staying on the warm anchor.
pub const SWITCH_COST_IN_USD: &str = "plano.switch.cost_in_usd";
/// Tokens the switch candidate still has cached from an earlier visit this session
/// (a return to a still-warm model). These re-read at the candidate's cached rate
/// instead of its uncached rate, which is why the switch cost can be far below a
/// full re-ingest. Zero for a first-time (cold) switch.
pub const SWITCH_CANDIDATE_WARM_TOKENS: &str = "plano.switch.candidate_warm_tokens";
/// The overhead ceiling (USD) available when the switch was evaluated —
/// `max_overhead_pct% * baseline`. A paid switch is allowed while cumulative spend
/// plus this switch's cost stays under it. Directly comparable to `cost_in_usd`.
pub const SWITCH_OVERHEAD_CEILING_IN_USD: &str = "plano.switch.overhead_ceiling_in_usd";
/// Switch outcome: "allowed" or "retained".
pub const SWITCH_DECISION: &str = "plano.switch.decision";
/// The route (`provider/model`, plus route name when routed) the routing-budget gate
/// *would* have selected had the switch been allowed. Recorded only on a `retained`
/// decision when `routing.routing_budget.record_counterfactual` is enabled.
/// Telemetry only — the counterfactual model is never dispatched.
pub const SWITCH_COUNTERFACTUAL_ROUTE: &str = "plano.switch.counterfactual_route";
}
// =============================================================================

View file

@ -33,6 +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.
pub routing_budget: Option<RoutingBudget>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -133,7 +137,7 @@ pub enum StateStorageType {
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[serde(rename_all = "snake_case")]
pub enum SelectionPreference {
Cheapest,
Fastest,
@ -218,6 +222,10 @@ pub struct Configuration {
pub model_aliases: Option<HashMap<String, ModelAlias>>,
pub overrides: Option<Overrides>,
pub routing: Option<Routing>,
/// Automatic provider prompt caching. Disabled by default; opt in globally with
/// `prompt_caching: { enabled: true }`. Applies across the entire Plano instance
/// and never changes which model routing selects.
pub prompt_caching: Option<PromptCaching>,
pub system_prompt: Option<String>,
pub prompt_guards: Option<PromptGuards>,
pub prompt_targets: Option<Vec<PromptTarget>>,
@ -244,6 +252,181 @@ pub struct Overrides {
pub disable_signals: Option<bool>,
}
/// Automatic prompt caching, configured once for the whole Plano instance.
///
/// Prompt caching keeps a multi-turn conversation's stable prefix warm in the
/// upstream provider's cache. It never influences which model routing selects — it
/// only (a) auto-injects provider cache-control markers where supported and
/// (b) derives an implicit session key from the stable prompt prefix so follow-up
/// turns reuse the same warm cache. An explicit `X-Model-Affinity` header always wins.
///
/// Disabled by default; opt in with `enabled: true`. The remaining knobs are optional
/// tuning that only take effect while caching is enabled.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PromptCaching {
/// Master switch. Defaults to `false` (opt-in).
#[serde(default)]
pub enabled: bool,
/// Derive an implicit session key from the stable prompt prefix so caches survive
/// across turns without client changes. Defaults to `true` when caching is enabled.
pub session_affinity: Option<bool>,
/// Auto-inject provider cache-control markers (e.g. Anthropic `cache_control`).
/// Defaults to `true` when caching is enabled.
pub inject_cache_control: Option<bool>,
/// Minimum estimated prefix tokens before a cache breakpoint is injected.
pub min_prefix_tokens: Option<u32>,
/// Session pin TTL; falls back to `routing.session_ttl_seconds` when unset.
pub session_ttl_seconds: Option<u64>,
}
/// A cumulative per-session overhead cap governing when routing may switch models.
///
/// This is a routing concern, not a caching one: it applies whenever configured,
/// regardless of whether `prompt_caching` is enabled. The default posture is to stick
/// to the model a session is warm on. When routing proposes a *different* model,
/// switching forces the candidate to re-ingest the whole context at its uncached input
/// rate. With prompt caching the anchor is warm, so the cost is the cache-loss delta
/// `context_tokens x (candidate_uncached_input_rate - anchor_cached_input_rate)`; without
/// caching there is no warm cache to lose, so it is
/// `context_tokens x (candidate_uncached_input_rate - anchor_uncached_input_rate)`. That
/// input-token cost accrues into the session's cumulative switch spend. The gate allows a paid switch
/// only while that spend stays within `max_overhead_pct` percent of the session's
/// running *never-switch* baseline (the cost the session would have paid by staying
/// on its anchor). A switch that is outright cheaper (negative cost) is free but
/// never credits the spend back — the "saving" is vs a path we didn't take, not real
/// spendable money. Requires a cost source in `model_metrics_sources` so per-model
/// rates are available. Presence of the block turns it on.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct RoutingBudget {
/// Cap on cumulative switching overhead, as a **percentage** of what the session
/// would have cost by never switching (a whole number: `20` = 20%). The promise
/// is "this conversation bills at most `max_overhead_pct`% above never-switching."
/// `0` means "never pay to switch" (only outright-cheaper switches are ever
/// allowed); larger values buy more quality-driven switches. Typical range 1030.
pub max_overhead_pct: f64,
/// Reset the running baseline/spend totals when a session goes cold and re-binds
/// (a fresh warm episode). Defaults to `true`.
#[serde(default = "default_true")]
pub replenish_on_rebind: bool,
/// Fallback used to estimate a model's cached input rate when the pricing feed
/// doesn't publish one: `cached_rate = input_rate * cache_read_discount`. A
/// pricing detail, not a cost policy. Defaults to 0.1 (cached reads at 10% of
/// input, typical for Anthropic-style caches).
pub cache_read_discount: Option<f64>,
/// When true, a vetoed switch records the route the gate *would* have taken had
/// the switch been allowed, as the `plano.switch.counterfactual_route` span
/// attribute. Telemetry only — the counterfactual model is never dispatched.
/// Useful for evals/benchmarks that want to quantify the road not taken.
/// Defaults to `false`.
#[serde(default)]
pub record_counterfactual: bool,
}
fn default_true() -> bool {
true
}
/// Fully-resolved routing-budget settings (present only when configured and valid).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EffectiveRoutingBudget {
/// Cumulative switching-overhead cap, as a percentage of the never-switch
/// baseline (a whole number: `20` = 20%).
pub max_overhead_pct: f64,
/// Reset the running baseline/spend totals on cold->warm re-bind.
pub replenish_on_rebind: bool,
pub cache_read_discount: f64,
/// Emit `plano.switch.counterfactual_route` on vetoed switches. Telemetry only.
pub record_counterfactual: bool,
}
pub const DEFAULT_CACHE_READ_DISCOUNT: f64 = 0.1;
impl RoutingBudget {
/// Resolve to effective settings, validating the overhead cap and cache-read
/// discount.
pub fn resolve(&self) -> Result<EffectiveRoutingBudget, String> {
if !self.max_overhead_pct.is_finite() || self.max_overhead_pct < 0.0 {
return Err(format!(
"routing.routing_budget.max_overhead_pct: must be a non-negative number (percent, e.g. 20 for 20%), got {}",
self.max_overhead_pct
));
}
let cache_read_discount = self
.cache_read_discount
.unwrap_or(DEFAULT_CACHE_READ_DISCOUNT);
if !(0.0..=1.0).contains(&cache_read_discount) {
return Err(format!(
"routing.routing_budget.cache_read_discount: must be between 0.0 and 1.0, got {cache_read_discount}"
));
}
Ok(EffectiveRoutingBudget {
max_overhead_pct: self.max_overhead_pct,
replenish_on_rebind: self.replenish_on_rebind,
cache_read_discount,
record_counterfactual: self.record_counterfactual,
})
}
}
impl EffectiveRoutingBudget {
/// Resolve from an optional config block; `None` means the gate is off.
pub fn from_config(config: Option<&RoutingBudget>) -> Result<Option<Self>, String> {
config.map(RoutingBudget::resolve).transpose()
}
}
/// Fully-resolved, instance-wide prompt-caching settings.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EffectivePromptCaching {
pub enabled: bool,
pub session_affinity: bool,
pub inject_cache_control: bool,
pub min_prefix_tokens: u32,
/// Pin TTL override; `None` uses `routing.session_ttl_seconds`.
pub session_ttl_seconds: Option<u64>,
}
pub const DEFAULT_MIN_PREFIX_TOKENS: u32 = 1024;
impl Default for EffectivePromptCaching {
fn default() -> Self {
EffectivePromptCaching {
enabled: false,
session_affinity: false,
inject_cache_control: false,
min_prefix_tokens: DEFAULT_MIN_PREFIX_TOKENS,
session_ttl_seconds: None,
}
}
}
impl PromptCaching {
/// Resolve the instance-wide effective settings. When caching is disabled every
/// sub-feature is off, regardless of the individual knobs.
pub fn resolve(&self) -> Result<EffectivePromptCaching, String> {
if !self.enabled {
return Ok(EffectivePromptCaching::default());
}
Ok(EffectivePromptCaching {
enabled: true,
session_affinity: self.session_affinity.unwrap_or(true),
inject_cache_control: self.inject_cache_control.unwrap_or(true),
min_prefix_tokens: self.min_prefix_tokens.unwrap_or(DEFAULT_MIN_PREFIX_TOKENS),
session_ttl_seconds: self.session_ttl_seconds,
})
}
}
impl EffectivePromptCaching {
/// Resolve from an optional config block; `None` means caching is off.
pub fn from_config(config: Option<&PromptCaching>) -> Result<Self, String> {
config
.map(PromptCaching::resolve)
.transpose()
.map(Option::unwrap_or_default)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Tracing {
pub sampling_rate: Option<f64>,
@ -699,7 +882,10 @@ mod test {
use pretty_assertions::assert_eq;
use std::fs;
use super::{IntoModels, LlmProvider, LlmProviderType};
use super::{
EffectivePromptCaching, EffectiveRoutingBudget, IntoModels, LlmProvider, LlmProviderType,
PromptCaching, RoutingBudget, DEFAULT_CACHE_READ_DISCOUNT, DEFAULT_MIN_PREFIX_TOKENS,
};
use crate::api::open_ai::ToolType;
#[test]
@ -901,6 +1087,128 @@ disable_signals: false
assert_eq!(overrides.disable_signals, None);
}
#[test]
fn test_prompt_caching_disabled_by_default() {
// Absent config → everything off.
let effective = EffectivePromptCaching::from_config(None).unwrap();
assert!(!effective.enabled);
assert!(!effective.session_affinity);
assert!(!effective.inject_cache_control);
// Present but not enabled → still off.
let cfg: PromptCaching = serde_yaml::from_str("enabled: false").unwrap();
let effective = cfg.resolve().unwrap();
assert!(!effective.enabled);
assert!(!effective.session_affinity);
assert!(!effective.inject_cache_control);
}
#[test]
fn test_prompt_caching_enabled_defaults() {
// A bare `enabled: true` turns everything on with sensible defaults.
let cfg: PromptCaching = serde_yaml::from_str("enabled: true").unwrap();
let effective = cfg.resolve().unwrap();
assert!(effective.enabled);
assert!(effective.session_affinity);
assert!(effective.inject_cache_control);
assert_eq!(effective.min_prefix_tokens, DEFAULT_MIN_PREFIX_TOKENS);
assert_eq!(effective.session_ttl_seconds, None);
}
#[test]
fn test_prompt_caching_optional_knobs() {
let yaml = r#"
enabled: true
session_affinity: false
inject_cache_control: false
min_prefix_tokens: 2048
session_ttl_seconds: 3600
"#;
let cfg: PromptCaching = serde_yaml::from_str(yaml).unwrap();
let effective = cfg.resolve().unwrap();
assert!(effective.enabled);
assert!(!effective.session_affinity);
assert!(!effective.inject_cache_control);
assert_eq!(effective.min_prefix_tokens, 2048);
assert_eq!(effective.session_ttl_seconds, Some(3600));
}
#[test]
fn test_prompt_caching_knobs_ignored_when_disabled() {
// Knobs only take effect while caching is enabled.
let yaml = r#"
enabled: false
session_affinity: true
inject_cache_control: true
"#;
let cfg: PromptCaching = serde_yaml::from_str(yaml).unwrap();
let effective = cfg.resolve().unwrap();
assert!(!effective.enabled);
assert!(!effective.session_affinity);
assert!(!effective.inject_cache_control);
}
#[test]
fn test_routing_budget_parses() {
let yaml = r#"
max_overhead_pct: 20
"#;
let cfg: RoutingBudget = serde_yaml::from_str(yaml).unwrap();
let budget = cfg.resolve().unwrap();
assert_eq!(budget.max_overhead_pct, 20.0);
// Replenish defaults on.
assert!(budget.replenish_on_rebind);
assert_eq!(budget.cache_read_discount, DEFAULT_CACHE_READ_DISCOUNT);
// Counterfactual recording is opt-in; off unless requested.
assert!(!budget.record_counterfactual);
}
#[test]
fn test_routing_budget_record_counterfactual_parses() {
let yaml = r#"
max_overhead_pct: 20
record_counterfactual: true
"#;
let cfg: RoutingBudget = serde_yaml::from_str(yaml).unwrap();
let budget = cfg.resolve().unwrap();
assert!(budget.record_counterfactual);
}
#[test]
fn test_routing_budget_flags_parse() {
let yaml = r#"
max_overhead_pct: 15
replenish_on_rebind: false
cache_read_discount: 0.25
"#;
let cfg: RoutingBudget = serde_yaml::from_str(yaml).unwrap();
let budget = cfg.resolve().unwrap();
assert_eq!(budget.max_overhead_pct, 15.0);
assert!(!budget.replenish_on_rebind);
assert_eq!(budget.cache_read_discount, 0.25);
}
#[test]
fn test_routing_budget_absent_is_off() {
// No block configured → gate is off.
assert!(EffectiveRoutingBudget::from_config(None).unwrap().is_none());
}
#[test]
fn test_routing_budget_invalid_values_rejected() {
let negative: RoutingBudget = serde_yaml::from_str("max_overhead_pct: -1.0").unwrap();
assert!(negative.resolve().is_err());
let bad_discount: RoutingBudget = serde_yaml::from_str(
r#"
max_overhead_pct: 20
cache_read_discount: 1.5
"#,
)
.unwrap();
assert!(bad_discount.resolve().is_err());
}
#[test]
fn test_tracing_posthog_exporter_deserialize() {
let yaml = r#"

View file

@ -23,6 +23,12 @@ pub const X_ARCH_FC_MODEL_RESPONSE: &str = "x-arch-fc-model-response";
pub const ARCH_FC_MODEL_NAME: &str = "Arch-Function";
pub const REQUEST_ID_HEADER: &str = "x-request-id";
pub const MODEL_AFFINITY_HEADER: &str = "x-model-affinity";
/// Per-request prompt-caching control. `off` disables implicit session affinity and
/// cache-control injection for that single request.
pub const PLANO_CACHE_HEADER: &str = "x-plano-cache";
/// Hash of the stable prompt prefix, forwarded upstream so self-hosted multi-replica
/// backends can do KV-aware (consistent-hash) replica routing at the LB/Envoy layer.
pub const PLANO_PREFIX_HASH_HEADER: &str = "x-plano-prefix-hash";
pub const ENVOY_ORIGINAL_PATH_HEADER: &str = "x-envoy-original-path";
pub const TRACE_PARENT_HEADER: &str = "traceparent";
pub const ARCH_INTERNAL_CLUSTER_NAME: &str = "arch_internal";

View file

@ -285,6 +285,7 @@ pub struct MessagesTool {
pub name: String,
pub description: Option<String>,
pub input_schema: Value,
pub cache_control: Option<MessagesCacheControl>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
@ -305,6 +306,155 @@ pub struct MessagesToolChoice {
pub disable_parallel_tool_use: Option<bool>,
}
/// Rough chars-per-token heuristic used to threshold-guard cache-marker injection.
/// Injection below the provider's minimum cacheable prefix is a provider-side no-op,
/// so a cheap estimate is sufficient here.
const CHARS_PER_TOKEN: usize = 4;
fn block_cache_control(block: &MessagesContentBlock) -> Option<&MessagesCacheControl> {
match block {
MessagesContentBlock::Text { cache_control, .. }
| MessagesContentBlock::Thinking { cache_control, .. }
| MessagesContentBlock::ToolUse { cache_control, .. }
| MessagesContentBlock::ToolResult { cache_control, .. } => cache_control.as_ref(),
_ => None,
}
}
/// Set `cache_control: ephemeral` on the last block that supports it.
/// Returns true if a marker was placed.
fn mark_last_cacheable_block(blocks: &mut [MessagesContentBlock]) -> bool {
for block in blocks.iter_mut().rev() {
match block {
MessagesContentBlock::Text { cache_control, .. }
| MessagesContentBlock::Thinking { cache_control, .. }
| MessagesContentBlock::ToolUse { cache_control, .. }
| MessagesContentBlock::ToolResult { cache_control, .. } => {
*cache_control = Some(MessagesCacheControl::Ephemeral);
return true;
}
_ => continue,
}
}
false
}
impl MessagesRequest {
/// True when the client already supplied any `cache_control` marker
/// (on system blocks, tools, or message content blocks).
pub fn has_cache_markers(&self) -> bool {
let system_marked = match &self.system {
Some(MessagesSystemPrompt::Blocks(blocks)) => {
blocks.iter().any(|b| block_cache_control(b).is_some())
}
_ => false,
};
let tools_marked = self
.tools
.as_ref()
.is_some_and(|tools| tools.iter().any(|t| t.cache_control.is_some()));
let messages_marked = self.messages.iter().any(|m| match &m.content {
MessagesMessageContent::Blocks(blocks) => {
blocks.iter().any(|b| block_cache_control(b).is_some())
}
MessagesMessageContent::Single(_) => false,
});
system_marked || tools_marked || messages_marked
}
/// Estimated token length of the stable prompt prefix (tools + system), which is
/// what precedes conversation history in Anthropic's prompt ordering.
pub fn estimated_prefix_tokens(&self) -> u32 {
let mut chars = 0usize;
if let Some(tools) = &self.tools {
for tool in tools {
chars += tool.name.len();
chars += tool.description.as_deref().map_or(0, str::len);
chars += tool.input_schema.to_string().len();
}
}
match &self.system {
Some(MessagesSystemPrompt::Single(text)) => chars += text.len(),
Some(MessagesSystemPrompt::Blocks(blocks)) => {
chars += blocks.extract_text().len();
}
None => {}
}
(chars / CHARS_PER_TOKEN) as u32
}
/// Auto-inject ephemeral cache breakpoints for providers that require explicit
/// markers (Anthropic-shaped requests):
///
/// 1. at the end of the system prompt (covering the fully-stable tools + system
/// prefix), falling back to the last tool when there is no system prompt, and
/// 2. a rolling breakpoint on the last content block of the final message, so each
/// turn's cache write becomes the next turn's cache read as history grows.
///
/// Idempotent: a request that already carries any client-supplied `cache_control`
/// marker is left untouched. Threshold-guarded: no-op when the estimated stable
/// prefix is below `min_prefix_tokens` (injection below the provider's minimum
/// cacheable prefix is wasted bytes).
///
/// Returns true if any marker was injected.
pub fn inject_cache_breakpoints(&mut self, min_prefix_tokens: u32) -> bool {
if self.has_cache_markers() {
return false;
}
if self.estimated_prefix_tokens() < min_prefix_tokens {
return false;
}
let mut injected = false;
// Breakpoint 1: end of the stable prefix.
match self.system.take() {
Some(MessagesSystemPrompt::Single(text)) => {
self.system = Some(MessagesSystemPrompt::Blocks(vec![
MessagesContentBlock::Text {
text,
cache_control: Some(MessagesCacheControl::Ephemeral),
},
]));
injected = true;
}
Some(MessagesSystemPrompt::Blocks(mut blocks)) => {
injected |= mark_last_cacheable_block(&mut blocks);
self.system = Some(MessagesSystemPrompt::Blocks(blocks));
}
None => {
if let Some(last_tool) = self.tools.as_mut().and_then(|t| t.last_mut()) {
last_tool.cache_control = Some(MessagesCacheControl::Ephemeral);
injected = true;
}
}
}
// Breakpoint 2: rolling tail of conversation history.
if let Some(last_msg) = self.messages.last_mut() {
let content = std::mem::replace(
&mut last_msg.content,
MessagesMessageContent::Single(String::new()),
);
last_msg.content = match content {
MessagesMessageContent::Single(text) => {
injected = true;
MessagesMessageContent::Blocks(vec![MessagesContentBlock::Text {
text,
cache_control: Some(MessagesCacheControl::Ephemeral),
}])
}
MessagesMessageContent::Blocks(mut blocks) => {
injected |= mark_last_cacheable_block(&mut blocks);
MessagesMessageContent::Blocks(blocks)
}
};
}
injected
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MessagesStopReason {
@ -680,6 +830,134 @@ mod tests {
use super::*;
use serde_json::json;
fn cache_test_request(system: Option<MessagesSystemPrompt>) -> MessagesRequest {
MessagesRequest {
model: "claude-sonnet-4".to_string(),
messages: vec![
MessagesMessage {
role: MessagesRole::User,
content: MessagesMessageContent::Single("first user turn".to_string()),
},
MessagesMessage {
role: MessagesRole::Assistant,
content: MessagesMessageContent::Single("assistant reply".to_string()),
},
MessagesMessage {
role: MessagesRole::User,
content: MessagesMessageContent::Single("second user turn".to_string()),
},
],
max_tokens: 100,
container: None,
mcp_servers: None,
system,
metadata: None,
service_tier: None,
thinking: None,
temperature: None,
top_p: None,
top_k: None,
stream: None,
stop_sequences: None,
tools: None,
tool_choice: None,
}
}
#[test]
fn test_inject_cache_breakpoints_marks_system_and_tail() {
let long_system = "x".repeat(8192); // ~2048 estimated tokens
let mut req = cache_test_request(Some(MessagesSystemPrompt::Single(long_system)));
assert!(req.inject_cache_breakpoints(1024));
// System converted to a marked block.
match &req.system {
Some(MessagesSystemPrompt::Blocks(blocks)) => {
assert_eq!(blocks.len(), 1);
assert!(matches!(
&blocks[0],
MessagesContentBlock::Text {
cache_control: Some(MessagesCacheControl::Ephemeral),
..
}
));
}
other => panic!("expected marked system blocks, got {:?}", other),
}
// Rolling breakpoint on the last message.
match &req.messages.last().unwrap().content {
MessagesMessageContent::Blocks(blocks) => {
assert!(matches!(
&blocks[0],
MessagesContentBlock::Text {
cache_control: Some(MessagesCacheControl::Ephemeral),
..
}
));
}
other => panic!("expected marked tail blocks, got {:?}", other),
}
}
#[test]
fn test_inject_cache_breakpoints_below_threshold_is_noop() {
let mut req = cache_test_request(Some(MessagesSystemPrompt::Single(
"short system".to_string(),
)));
assert!(!req.inject_cache_breakpoints(1024));
assert!(matches!(req.system, Some(MessagesSystemPrompt::Single(_))));
}
#[test]
fn test_inject_cache_breakpoints_respects_client_markers() {
let long_system = "x".repeat(8192);
let mut req = cache_test_request(Some(MessagesSystemPrompt::Blocks(vec![
MessagesContentBlock::Text {
text: long_system,
cache_control: Some(MessagesCacheControl::Ephemeral),
},
])));
// Client already placed a marker: injection must be a no-op.
assert!(!req.inject_cache_breakpoints(1024));
// Tail message untouched.
assert!(matches!(
req.messages.last().unwrap().content,
MessagesMessageContent::Single(_)
));
}
#[test]
fn test_inject_cache_breakpoints_marks_last_tool_when_no_system() {
let mut req = cache_test_request(None);
req.tools = Some(vec![MessagesTool {
name: "big_tool".to_string(),
description: Some("d".repeat(8192)),
input_schema: json!({"type": "object"}),
cache_control: None,
}]);
assert!(req.inject_cache_breakpoints(1024));
assert_eq!(
req.tools.as_ref().unwrap().last().unwrap().cache_control,
Some(MessagesCacheControl::Ephemeral)
);
}
#[test]
fn test_tool_cache_control_roundtrip() {
// Client-supplied cache_control on tools must survive serde passthrough.
let tool_json = json!({
"name": "get_weather",
"input_schema": {"type": "object"},
"cache_control": {"type": "ephemeral"}
});
let tool: MessagesTool = serde_json::from_value(tool_json.clone()).unwrap();
assert_eq!(tool.cache_control, Some(MessagesCacheControl::Ephemeral));
assert_eq!(serde_json::to_value(&tool).unwrap(), tool_json);
}
#[test]
fn test_anthropic_required_fields() {
// Create a JSON object with only required fields

View file

@ -163,6 +163,112 @@ impl ChatCompletionsRequest {
self.store = None;
}
}
/// True when any message content part already carries a `cache_control` marker
/// (client-supplied or previously injected).
pub fn has_cache_control_markers(&self) -> bool {
self.messages.iter().any(|m| match &m.content {
Some(MessageContent::Parts(parts)) => parts.iter().any(|p| {
matches!(
p,
ContentPart::Text {
cache_control: Some(_),
..
}
)
}),
_ => false,
})
}
/// Estimated token length of the stable prompt prefix — the system/developer
/// message(s) plus tool definitions — using a chars/4 heuristic. Precision is
/// not required; this only gates whether marking the prefix is worthwhile.
pub fn estimated_cache_prefix_tokens(&self) -> u32 {
const CHARS_PER_TOKEN: usize = 4;
let mut chars = 0usize;
for m in &self.messages {
if matches!(m.role, Role::System | Role::Developer) {
if let Some(content) = &m.content {
chars += content.extract_text().len();
}
}
}
if let Some(tools) = &self.tools {
for t in tools {
chars += t.function.name.len();
chars += t.function.description.as_deref().map_or(0, str::len);
chars += t.function.parameters.to_string().len();
}
}
(chars / CHARS_PER_TOKEN) as u32
}
/// Auto-inject a single ephemeral `cache_control` breakpoint at the end of the
/// stable prompt prefix, for OpenAI-compatible gateways that proxy Anthropic-family
/// models (DigitalOcean, OpenRouter). The marker is attached to the last text
/// content part of the system/developer message (falling back to the first message
/// when there is no system prompt), normalizing a plain-string content into a
/// one-element content-part array as needed.
///
/// Idempotent: a request that already carries any `cache_control` marker is left
/// untouched. Threshold-guarded: no-op when the estimated stable prefix is below
/// `min_prefix_tokens`. Returns true if a marker was injected.
pub fn inject_cache_control(&mut self, ttl: Option<String>, min_prefix_tokens: u32) -> bool {
if self.has_cache_control_markers() {
return false;
}
if self.estimated_cache_prefix_tokens() < min_prefix_tokens {
return false;
}
let target_idx = self
.messages
.iter()
.position(|m| matches!(m.role, Role::System | Role::Developer))
.or(if self.messages.is_empty() {
None
} else {
Some(0)
});
let Some(idx) = target_idx else {
return false;
};
let marker = CacheControl {
kind: "ephemeral".to_string(),
ttl,
};
attach_cache_control(&mut self.messages[idx], marker)
}
}
/// Attach a `cache_control` marker to the last text content part of `message`,
/// normalizing string content into a one-element parts array. Returns false when
/// there is no text part to mark (e.g. an image-only message).
fn attach_cache_control(message: &mut Message, marker: CacheControl) -> bool {
let mut parts = match message.content.take() {
Some(MessageContent::Text(text)) => vec![ContentPart::Text {
text,
cache_control: None,
}],
Some(MessageContent::Parts(parts)) => parts,
None => return false,
};
let marked = if let Some(ContentPart::Text { cache_control, .. }) = parts
.iter_mut()
.rev()
.find(|p| matches!(p, ContentPart::Text { .. }))
{
*cache_control = Some(marker);
true
} else {
false
};
message.content = Some(MessageContent::Parts(parts));
marked
}
/// True when the upstream model id is Moonshot's Kimi Code endpoint model.
@ -277,7 +383,7 @@ impl ExtractText for Vec<ContentPart> {
fn extract_text(&self) -> String {
self.iter()
.filter_map(|part| match part {
ContentPart::Text { text } => Some(text.as_str()),
ContentPart::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
@ -296,11 +402,32 @@ impl Display for MessageContent {
#[serde(tag = "type")]
pub enum ContentPart {
#[serde(rename = "text")]
Text { text: String },
Text {
text: String,
/// Prompt-cache breakpoint for OpenAI-compatible gateways that proxy
/// Anthropic-family models (e.g. DigitalOcean, OpenRouter). Round-trips so
/// client-supplied markers survive deserialization and are respected by the
/// idempotent injector.
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
#[serde(rename = "image_url")]
ImageUrl { image_url: ImageUrl },
}
/// Prompt-cache control marker carried on an OpenAI content part. Mirrors the
/// Anthropic `cache_control` object as accepted by OpenAI-compatible gateways that
/// front Anthropic models.
#[skip_serializing_none]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct CacheControl {
#[serde(rename = "type")]
pub kind: String, // "ephemeral"
/// Cache lifetime hint: "5m" | "1h" (DigitalOcean / OpenRouter). Omitted for
/// plain Anthropic ephemeral caching (defaults to 5 minutes upstream).
pub ttl: Option<String>,
}
/// Image URL configuration for vision capabilities
#[skip_serializing_none]
#[derive(Serialize, Deserialize, Debug, Clone)]
@ -445,11 +572,35 @@ pub struct Usage {
pub total_tokens: u32,
pub prompt_tokens_details: Option<PromptTokensDetails>,
pub completion_tokens_details: Option<CompletionTokensDetails>,
/// Anthropic-style cache-read counter emitted by OpenAI-compatible gateways that
/// front Anthropic models (e.g. DigitalOcean), which do *not* populate
/// `prompt_tokens_details.cached_tokens`. Captured here so it can be surfaced to
/// OpenAI clients via [`Usage::normalize_cache_tokens`].
pub cache_read_input_tokens: Option<u32>,
/// Anthropic-style cache-write counter (see `cache_read_input_tokens`).
pub cache_creation_input_tokens: Option<u32>,
}
impl Usage {
/// Fold gateway-specific Anthropic-style `cache_read_input_tokens` into the
/// OpenAI-standard `prompt_tokens_details.cached_tokens` so OpenAI-compatible
/// clients (and downstream cost accounting) observe the cache hit. No-op when the
/// standard field is already populated or no cache-read counter is present.
pub fn normalize_cache_tokens(&mut self) {
if let Some(read) = self.cache_read_input_tokens {
let details = self
.prompt_tokens_details
.get_or_insert_with(Default::default);
if details.cached_tokens.is_none() {
details.cached_tokens = Some(read);
}
}
}
}
/// Detailed breakdown of prompt tokens
#[skip_serializing_none]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct PromptTokensDetails {
pub cached_tokens: Option<u32>,
pub audio_tokens: Option<u32>,
@ -632,7 +783,15 @@ impl TokenUsage for Usage {
fn cached_input_tokens(&self) -> Option<usize> {
self.prompt_tokens_details
.as_ref()
.and_then(|d| d.cached_tokens.map(|t| t as usize))
.and_then(|d| d.cached_tokens)
// Gateways fronting Anthropic (e.g. DigitalOcean) report cache reads here
// instead of prompt_tokens_details.cached_tokens.
.or(self.cache_read_input_tokens)
.map(|t| t as usize)
}
fn cache_creation_tokens(&self) -> Option<usize> {
self.cache_creation_input_tokens.map(|t| t as usize)
}
fn reasoning_tokens(&self) -> Option<usize> {
@ -666,7 +825,7 @@ impl ProviderRequest for ChatCompletionsRequest {
MessageContent::Parts(parts) => parts
.iter()
.map(|part| match part {
ContentPart::Text { text } => text.clone(),
ContentPart::Text { text, .. } => text.clone(),
ContentPart::ImageUrl { .. } => "[Image]".to_string(),
})
.collect::<Vec<_>>()
@ -796,6 +955,137 @@ mod tests {
use super::*;
use serde_json::json;
/// Build a request with a long system prompt (well over the token threshold) plus
/// a short user turn.
fn request_with_system(system: &str, user: &str) -> ChatCompletionsRequest {
ChatCompletionsRequest {
model: "anthropic/claude-3.5-sonnet".to_string(),
messages: vec![
Message {
role: Role::System,
content: Some(MessageContent::Text(system.to_string())),
name: None,
tool_calls: None,
tool_call_id: None,
},
Message {
role: Role::User,
content: Some(MessageContent::Text(user.to_string())),
name: None,
tool_calls: None,
tool_call_id: None,
},
],
..Default::default()
}
}
fn system_cache_control(req: &ChatCompletionsRequest) -> Option<&CacheControl> {
match &req.messages[0].content {
Some(MessageContent::Parts(parts)) => parts.iter().find_map(|p| match p {
ContentPart::Text { cache_control, .. } => cache_control.as_ref(),
_ => None,
}),
_ => None,
}
}
#[test]
fn inject_cache_control_normalizes_string_content_and_marks_prefix() {
let mut req = request_with_system(&"x".repeat(8000), "hi");
let injected = req.inject_cache_control(None, 1024);
assert!(injected);
// The plain-string system content was normalized into a one-element parts array.
let marker = system_cache_control(&req).expect("system content part should be marked");
assert_eq!(marker.kind, "ephemeral");
assert_eq!(marker.ttl, None);
// The user message is untouched.
assert!(matches!(
req.messages[1].content,
Some(MessageContent::Text(_))
));
}
#[test]
fn inject_cache_control_is_idempotent() {
let mut req = request_with_system(&"x".repeat(8000), "hi");
assert!(req.inject_cache_control(Some("1h".to_string()), 1024));
// A second pass must be a no-op because a marker already exists.
assert!(!req.inject_cache_control(None, 1024));
assert_eq!(
system_cache_control(&req).and_then(|c| c.ttl.clone()),
Some("1h".to_string())
);
}
#[test]
fn inject_cache_control_respects_min_prefix_threshold() {
// A tiny prefix (below the threshold) is not worth marking.
let mut req = request_with_system("short system", "hi");
assert!(!req.inject_cache_control(None, 1024));
assert!(system_cache_control(&req).is_none());
}
#[test]
fn client_supplied_cache_control_round_trips_and_blocks_injection() {
// A client that already set cache_control must survive deserialization and be
// respected (no double injection).
let raw = json!({
"model": "anthropic/claude-3.5-sonnet",
"messages": [
{
"role": "system",
"content": [
{"type": "text", "text": "big stable prefix",
"cache_control": {"type": "ephemeral", "ttl": "5m"}}
]
},
{"role": "user", "content": "hi"}
]
});
let mut req: ChatCompletionsRequest = serde_json::from_value(raw).unwrap();
assert!(req.has_cache_control_markers());
// Injection is a no-op, and re-serialization preserves the client's marker.
assert!(!req.inject_cache_control(None, 0));
let reserialized = serde_json::to_value(&req).unwrap();
let marker = &reserialized["messages"][0]["content"][0]["cache_control"];
assert_eq!(marker["type"], "ephemeral");
assert_eq!(marker["ttl"], "5m");
}
#[test]
fn cache_control_omitted_when_absent() {
// A plain text part must not emit a null cache_control field.
let part = ContentPart::Text {
text: "hello".to_string(),
cache_control: None,
};
let v = serde_json::to_value(&part).unwrap();
assert!(v.get("cache_control").is_none());
}
#[test]
fn usage_normalizes_gateway_cache_read_into_cached_tokens() {
// DigitalOcean-style usage: Anthropic cache_read with no prompt_tokens_details.
let raw = json!({
"prompt_tokens": 100,
"completion_tokens": 20,
"total_tokens": 120,
"cache_read_input_tokens": 80,
"cache_creation_input_tokens": 12
});
let mut usage: Usage = serde_json::from_value(raw).unwrap();
// TokenUsage falls back to the gateway field.
assert_eq!(usage.cached_input_tokens(), Some(80));
assert_eq!(usage.cache_creation_tokens(), Some(12));
// Normalization surfaces it as the OpenAI-standard cached_tokens.
usage.normalize_cache_tokens();
assert_eq!(
usage.prompt_tokens_details.and_then(|d| d.cached_tokens),
Some(80)
);
}
#[test]
fn test_required_fields() {
// Create a JSON object with only required fields
@ -995,7 +1285,7 @@ mod tests {
assert_eq!(content_parts.len(), 2);
// Validate text content part
if let ContentPart::Text { text } = &content_parts[0] {
if let ContentPart::Text { text, .. } = &content_parts[0] {
assert_eq!(text, "What can you see in this image and what's the weather like in the location shown?");
} else {
panic!("Expected text content part");

View file

@ -6,10 +6,14 @@ pub mod clients;
pub mod providers;
pub mod transforms;
// Re-export important types and traits
pub use apis::openai::CacheControl;
pub use apis::streaming_shapes::amazon_bedrock_binary_frame::BedrockBinaryFrameDecoder;
pub use apis::streaming_shapes::sse::{SseEvent, SseStreamIter};
pub use aws_smithy_eventstream::frame::DecodedFrame;
pub use providers::id::ProviderId;
pub use providers::id::{
cache_marker_strategy, provider_cache_capability, CacheMarkerStrategy, ProviderCacheCapability,
ProviderId,
};
pub use providers::request::{ProviderRequest, ProviderRequestError, ProviderRequestType};
pub use providers::response::{
ProviderResponse, ProviderResponseError, ProviderResponseType, TokenUsage,

View file

@ -4,6 +4,7 @@ use serde::Deserialize;
use std::collections::HashMap;
use std::fmt::Display;
use std::sync::OnceLock;
use std::time::Duration;
static PROVIDER_MODELS_YAML: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
@ -94,6 +95,192 @@ impl TryFrom<&str> for ProviderId {
}
}
/// How Plano should mark a request for prompt caching, resolved from the *combination*
/// of gateway provider, the underlying model family, and the upstream API shape — not
/// the gateway alone. This is what lets DigitalOcean-Anthropic and OpenRouter-Anthropic
/// (both OpenAI-compatible chat completions fronting Anthropic models) cache through one
/// path, while OpenAI-family models stay correctly automatic and unimplemented backends
/// (Bedrock) are an honest `None` rather than a silent no-op.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CacheMarkerStrategy {
/// No known prompt-caching support for this combination — do nothing.
None,
/// Provider caches stable prefixes automatically (OpenAI-family anywhere); no
/// request markers are needed. Plano only keeps the prefix byte-stable and pinned.
Automatic,
/// OpenAI-compatible chat completions fronting Anthropic-family models
/// (DigitalOcean, OpenRouter): attach `cache_control` to content parts.
OpenAiContentPartCacheControl {
/// Minimum cacheable prefix length in tokens; injecting below this is a no-op.
min_prefix_tokens: u32,
/// Optional cache lifetime hint ("5m" | "1h").
ttl: Option<String>,
},
/// Native Anthropic Messages API (native `anthropic/*`, Vercel-Anthropic): inject
/// ephemeral breakpoints on the Anthropic-shaped request.
AnthropicMessagesBreakpoints {
/// Maximum number of cache breakpoints the provider accepts per request.
max_breakpoints: u8,
/// Minimum cacheable prefix length in tokens; injecting below this is a no-op.
min_prefix_tokens: u32,
},
// BedrockCachePoint { .. } // left as explicit `None` until implemented.
}
/// Coarse model family, inferred from the model id. Works across gateway naming
/// conventions (DigitalOcean's `anthropic-claude-…` dash form and OpenRouter's
/// `anthropic/claude-…` slash form) via substring matching.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModelFamily {
Anthropic,
OpenAI,
Other,
}
fn model_family(model_name: &str) -> ModelFamily {
let m = model_name.to_ascii_lowercase();
if m.contains("claude") || m.contains("anthropic") {
ModelFamily::Anthropic
} else if m.contains("gpt") || m.contains("openai") || m.contains("chatgpt") {
ModelFamily::OpenAI
} else {
ModelFamily::Other
}
}
/// Whether a gateway accepts Anthropic-style `cache_control` on OpenAI content parts
/// over its chat-completions endpoint.
fn accepts_openai_content_part_cache_control(provider: ProviderId) -> bool {
matches!(provider, ProviderId::DigitalOcean | ProviderId::OpenRouter)
}
/// Whether a gateway/model relies on automatic prefix caching (no markers required)
/// over an OpenAI-compatible surface.
fn is_automatic_cache_provider(provider: ProviderId) -> bool {
matches!(
provider,
ProviderId::OpenAI
| ProviderId::AzureOpenAI
| ProviderId::ChatGPT
| ProviderId::Groq
| ProviderId::Deepseek
| ProviderId::Gemini
| ProviderId::Moonshotai
| ProviderId::XAI
| ProviderId::DigitalOcean
| ProviderId::OpenRouter
)
}
/// Resolve the cache-marking strategy for a `(gateway provider × underlying model ×
/// upstream API)` combination.
///
/// - `model_name` is the id *after* the gateway prefix (e.g. `anthropic-claude-3-5-sonnet`
/// for DigitalOcean, `anthropic/claude-3.5-sonnet` for OpenRouter).
pub fn cache_marker_strategy(
provider: ProviderId,
model_name: &str,
upstream_api: &SupportedUpstreamAPIs,
) -> CacheMarkerStrategy {
// Anthropic minimum cacheable prefix is ~1024 tokens (2048 for Haiku-class);
// callers may raise this via config.
const ANTHROPIC_MIN_PREFIX_TOKENS: u32 = 1024;
match upstream_api {
// Native Anthropic Messages API — inject ephemeral breakpoints.
SupportedUpstreamAPIs::AnthropicMessagesAPI(_) => {
CacheMarkerStrategy::AnthropicMessagesBreakpoints {
max_breakpoints: 4,
min_prefix_tokens: ANTHROPIC_MIN_PREFIX_TOKENS,
}
}
// OpenAI-compatible chat completions — strategy depends on the model family.
SupportedUpstreamAPIs::OpenAIChatCompletions(_) => match model_family(model_name) {
ModelFamily::Anthropic if accepts_openai_content_part_cache_control(provider) => {
CacheMarkerStrategy::OpenAiContentPartCacheControl {
min_prefix_tokens: ANTHROPIC_MIN_PREFIX_TOKENS,
ttl: None,
}
}
// Anthropic-family behind a gateway that doesn't accept content-part
// cache_control over chat completions: no honest way to mark it.
ModelFamily::Anthropic => CacheMarkerStrategy::None,
ModelFamily::OpenAI => CacheMarkerStrategy::Automatic,
ModelFamily::Other if is_automatic_cache_provider(provider) => {
CacheMarkerStrategy::Automatic
}
ModelFamily::Other => CacheMarkerStrategy::None,
},
// OpenAI Responses API — OpenAI-family automatic prefix caching.
SupportedUpstreamAPIs::OpenAIResponsesAPI(_) => CacheMarkerStrategy::Automatic,
// Bedrock cache points not yet implemented — honest None instead of a
// silent no-op.
SupportedUpstreamAPIs::AmazonBedrockConverse(_)
| SupportedUpstreamAPIs::AmazonBedrockConverseStream(_) => CacheMarkerStrategy::None,
}
}
/// Provider prompt-cache retention behavior, used to decide whether a session's
/// upstream cache is still plausibly warm from the time since it was last used.
///
/// This is deliberately time/behavior only — it says nothing about *how* to mark a
/// request for caching (that's [`CacheMarkerStrategy`]). Warmth is a function of the
/// idle gap vs the provider's cache window, so the session router can reason about
/// stickiness without ever seeing a provider response.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderCacheCapability {
/// Sliding idle window: the cache stays warm as long as it is touched at least
/// this often. Anthropic's default ephemeral cache is 5 minutes.
pub idle_ttl: Duration,
/// Absolute ceiling on how long a cache entry can live regardless of activity.
/// Conservative default of 1h matches Anthropic's extended (1h) tier ceiling.
pub hard_ttl: Duration,
/// Whether the provider (as configured) actually retains caches out to the
/// extended window. Off by default — extended retention is opt-in per provider.
pub extended_retention: bool,
/// The extended idle window when `extended_retention` is enabled (e.g. 1h).
pub extended_ttl: Duration,
}
impl Default for ProviderCacheCapability {
fn default() -> Self {
// Conservative, provider-agnostic defaults: a 5-minute sliding window capped
// at 1 hour, no extended retention. Anything unknown is treated as short-lived
// so the router doesn't over-stick to a cache that has likely gone cold.
ProviderCacheCapability {
idle_ttl: Duration::from_secs(5 * 60),
hard_ttl: Duration::from_secs(60 * 60),
extended_retention: false,
extended_ttl: Duration::from_secs(60 * 60),
}
}
}
/// Resolve the prompt-cache retention window for a gateway provider. Data-driven so
/// tuning a provider's window needs no code changes at the call sites — only this
/// table. Unknown providers fall back to the conservative [`ProviderCacheCapability::default`].
pub fn provider_cache_capability(provider: ProviderId) -> ProviderCacheCapability {
match provider {
// Anthropic-family caches (native or fronted): 5-minute sliding default,
// 1-hour hard ceiling. Extended (1h) retention is opt-in and left off here.
ProviderId::Anthropic
| ProviderId::DigitalOcean
| ProviderId::OpenRouter
| ProviderId::Vercel => ProviderCacheCapability::default(),
// OpenAI-family automatic prefix caching also lives on the order of minutes;
// the conservative default holds.
ProviderId::OpenAI
| ProviderId::AzureOpenAI
| ProviderId::ChatGPT
| ProviderId::Groq
| ProviderId::Deepseek
| ProviderId::Gemini
| ProviderId::Moonshotai
| ProviderId::XAI => ProviderCacheCapability::default(),
_ => ProviderCacheCapability::default(),
}
}
impl ProviderId {
/// Get all available models for this provider
/// Returns model names without the provider prefix (e.g., "gpt-4" not "openai/gpt-4")
@ -297,6 +484,100 @@ impl Display for ProviderId {
#[cfg(test)]
mod tests {
use super::*;
use crate::apis::{AnthropicApi, OpenAIApi};
fn chat_completions() -> SupportedUpstreamAPIs {
SupportedUpstreamAPIs::OpenAIChatCompletions(OpenAIApi::ChatCompletions)
}
fn anthropic_messages() -> SupportedUpstreamAPIs {
SupportedUpstreamAPIs::AnthropicMessagesAPI(AnthropicApi::Messages)
}
#[test]
fn digitalocean_anthropic_uses_openai_content_part_markers() {
// DO fronts Anthropic over an OpenAI-compatible surface (dash-form model id).
let strategy = cache_marker_strategy(
ProviderId::DigitalOcean,
"anthropic-claude-3-5-sonnet",
&chat_completions(),
);
assert!(matches!(
strategy,
CacheMarkerStrategy::OpenAiContentPartCacheControl { .. }
));
}
#[test]
fn openrouter_anthropic_uses_openai_content_part_markers() {
// OpenRouter uses slash-form model ids after the gateway prefix.
let strategy = cache_marker_strategy(
ProviderId::OpenRouter,
"anthropic/claude-3.5-sonnet",
&chat_completions(),
);
assert!(matches!(
strategy,
CacheMarkerStrategy::OpenAiContentPartCacheControl { .. }
));
}
#[test]
fn openai_family_over_chat_completions_is_automatic() {
assert_eq!(
cache_marker_strategy(
ProviderId::DigitalOcean,
"openai-gpt-4o",
&chat_completions()
),
CacheMarkerStrategy::Automatic
);
assert_eq!(
cache_marker_strategy(ProviderId::OpenAI, "gpt-4o", &chat_completions()),
CacheMarkerStrategy::Automatic
);
}
#[test]
fn native_anthropic_uses_messages_breakpoints() {
let strategy = cache_marker_strategy(
ProviderId::Anthropic,
"claude-3-5-sonnet-20241022",
&anthropic_messages(),
);
assert!(matches!(
strategy,
CacheMarkerStrategy::AnthropicMessagesBreakpoints { .. }
));
}
#[test]
fn anthropic_family_without_content_part_support_is_none() {
// An Anthropic-family model over chat completions on a gateway that does not
// accept content-part cache_control has no honest marking path.
assert_eq!(
cache_marker_strategy(
ProviderId::Vercel,
"anthropic/claude-3.5",
&chat_completions()
),
CacheMarkerStrategy::None
);
}
#[test]
fn bedrock_is_honest_none() {
assert_eq!(
cache_marker_strategy(
ProviderId::AmazonBedrock,
"anthropic.claude-3-5-sonnet",
&SupportedUpstreamAPIs::AmazonBedrockConverse(
crate::apis::AmazonBedrockApi::Converse
)
),
CacheMarkerStrategy::None
);
}
#[test]
fn test_models_loaded_from_yaml() {

View file

@ -68,7 +68,10 @@ impl ContentUtils<ToolCall> for Vec<MessagesContentBlock> {
for block in self {
match block {
MessagesContentBlock::Text { text, .. } => {
content_parts.push(ContentPart::Text { text: text.clone() });
content_parts.push(ContentPart::Text {
text: text.clone(),
cache_control: None,
});
}
MessagesContentBlock::Image { source } => {
let url = convert_image_source_to_url(source);
@ -198,7 +201,7 @@ pub fn convert_openai_message_to_anthropic_content(
Some(MessageContent::Parts(parts)) => {
for part in parts {
match part {
ContentPart::Text { text } => {
ContentPart::Text { text, .. } => {
blocks.push(MessagesContentBlock::Text {
text: text.clone(),
cache_control: None,

View file

@ -324,7 +324,7 @@ fn build_openai_content(
None
} else if content_parts.len() == 1 && tool_calls.is_empty() {
match &content_parts[0] {
ContentPart::Text { text } => Some(MessageContent::Text(text.clone())),
ContentPart::Text { text, .. } => Some(MessageContent::Text(text.clone())),
_ => Some(MessageContent::Parts(content_parts)),
}
} else if content_parts.is_empty() {
@ -562,6 +562,7 @@ mod tests {
},
"required": ["location"]
}),
cache_control: None,
}]),
tool_choice: Some(MessagesToolChoice {
kind: MessagesToolChoiceType::Tool,
@ -620,6 +621,7 @@ mod tests {
"type": "object",
"properties": {}
}),
cache_control: None,
}]),
tool_choice: Some(MessagesToolChoice {
kind: MessagesToolChoiceType::Auto,

View file

@ -127,6 +127,7 @@ impl TryFrom<ResponsesInputConverter> for Vec<Message> {
| InputContent::OutputText { text } => {
Some(crate::apis::openai::ContentPart::Text {
text: text.clone(),
cache_control: None,
})
}
InputContent::InputImage { image_url, .. } => {
@ -154,6 +155,7 @@ impl TryFrom<ResponsesInputConverter> for Vec<Message> {
| InputContent::OutputText { text } => {
Some(crate::apis::openai::ContentPart::Text {
text: text.clone(),
cache_control: None,
})
}
InputContent::InputImage { image_url, .. } => {
@ -330,7 +332,7 @@ impl TryFrom<Message> for BedrockMessage {
// Convert OpenAI content parts to Bedrock ContentBlocks
for part in parts {
match part {
crate::apis::openai::ContentPart::Text { text } => {
crate::apis::openai::ContentPart::Text { text, .. } => {
if !text.is_empty() {
content_blocks.push(ContentBlock::Text { text });
}
@ -882,6 +884,7 @@ fn convert_openai_tools(tools: Vec<Tool>) -> Vec<MessagesTool> {
name: tool.function.name,
description: tool.function.description,
input_schema: tool.function.parameters,
cache_control: None,
})
.collect()
}

View file

@ -14,13 +14,18 @@ use crate::transforms::lib::*;
// Usage Conversions
impl From<MessagesUsage> for Usage {
fn from(val: MessagesUsage) -> Self {
Usage {
let mut usage = Usage {
prompt_tokens: val.input_tokens,
completion_tokens: val.output_tokens,
total_tokens: val.input_tokens + val.output_tokens,
prompt_tokens_details: None,
completion_tokens_details: None,
}
cache_read_input_tokens: val.cache_read_input_tokens,
cache_creation_input_tokens: val.cache_creation_input_tokens,
};
// Surface Anthropic cache reads to OpenAI clients as cached_tokens.
usage.normalize_cache_tokens();
usage
}
}
@ -244,6 +249,7 @@ impl TryFrom<MessagesResponse> for ChatCompletionsResponse {
total_tokens: resp.usage.input_tokens + resp.usage.output_tokens,
prompt_tokens_details: None,
completion_tokens_details: None,
..Default::default()
};
Ok(ChatCompletionsResponse {
@ -312,6 +318,7 @@ impl TryFrom<ConverseResponse> for ChatCompletionsResponse {
total_tokens: resp.usage.total_tokens,
prompt_tokens_details: None,
completion_tokens_details: None,
..Default::default()
};
// Generate a response ID (using timestamp since Bedrock doesn't provide one)
@ -980,6 +987,7 @@ mod tests {
total_tokens: 30,
prompt_tokens_details: None,
completion_tokens_details: None,
..Default::default()
},
system_fingerprint: None,
service_tier: Some("default".to_string()),
@ -1061,6 +1069,7 @@ mod tests {
total_tokens: 40,
prompt_tokens_details: None,
completion_tokens_details: None,
..Default::default()
},
system_fingerprint: None,
service_tier: None,
@ -1134,6 +1143,7 @@ mod tests {
total_tokens: 101,
prompt_tokens_details: None,
completion_tokens_details: None,
..Default::default()
},
system_fingerprint: Some("fp_7eeb46f068".to_string()),
service_tier: Some("default".to_string()),

View file

@ -247,6 +247,7 @@ impl TryFrom<ConverseStreamEvent> for ChatCompletionsStreamResponse {
total_tokens: metadata_event.usage.total_tokens,
prompt_tokens_details: None,
completion_tokens_details: None,
..Default::default()
};
Ok(create_openai_chunk(

View file

@ -108,10 +108,10 @@ The response contains the model list — your client should try `models[0]` firs
## Session Pinning
Send an `X-Model-Affinity` header to pin the routing decision for a session. Once a model is selected, all subsequent requests with the same session ID return the same model without re-running routing.
Send an `X-Model-Affinity` header to give a session a stable identity. Routing still runs on every request — pinning means the session sticks to its anchor model while the session is warm (recently used), so the provider-side prompt cache stays hot. The `pinned` field in the response signals a warm, stuck session. If a `routing_budget` is configured, a proposed switch away from the anchor is additionally gated by cost (see the [routing_budget demo](../routing_budget/)).
```bash
# First call — runs routing, caches result
# First call — creates the session binding
curl http://localhost:12000/routing/v1/chat/completions \
-H "Content-Type: application/json" \
-H "X-Model-Affinity: my-session-123" \
@ -121,10 +121,10 @@ curl http://localhost:12000/routing/v1/chat/completions \
}'
```
Response (first call):
Response (first call — session is new, not yet warm):
```json
{
"model": "anthropic/claude-sonnet-4-6",
"models": ["anthropic/claude-sonnet-4-6", "openai/gpt-4o"],
"route": "code_generation",
"trace_id": "c16d1096c1af4a17abb48fb182918a88",
"session_id": "my-session-123",
@ -133,7 +133,7 @@ Response (first call):
```
```bash
# Second call — same session, returns cached result
# Second call — same session, sticks to the anchor while warm
curl http://localhost:12000/routing/v1/chat/completions \
-H "Content-Type: application/json" \
-H "X-Model-Affinity: my-session-123" \
@ -143,10 +143,10 @@ curl http://localhost:12000/routing/v1/chat/completions \
}'
```
Response (pinned):
Response (warm session — `pinned: true`, the anchor model leads the list):
```json
{
"model": "anthropic/claude-sonnet-4-6",
"models": ["anthropic/claude-sonnet-4-6", "openai/gpt-4o"],
"route": "code_generation",
"trace_id": "a1b2c3d4e5f6...",
"session_id": "my-session-123",
@ -161,7 +161,7 @@ routing:
session_max_entries: 10000 # default: 10000
```
Without the `X-Model-Affinity` header, routing runs fresh every time (no breaking change).
Without the `X-Model-Affinity` header, sessions can still be pinned implicitly when prompt caching or a routing budget is enabled — a session key is derived from the system prompt + tools + first user message. With neither enabled (as in this demo's config), every request routes fresh (no breaking change).
## Kubernetes Deployment (Self-hosted Plano-Orchestrator on GPU)
@ -269,9 +269,9 @@ kubectl rollout restart deployment/plano
}
--- 8. Session pinning - second call (same session, pinned) ---
Notice: same model returned with "pinned": true, routing was skipped
Notice: same anchor model returned with "pinned": true (warm session)
{
"model": "anthropic/claude-sonnet-4-6",
"models": ["anthropic/claude-sonnet-4-6", "openai/gpt-4o"],
"route": "code_generation",
"trace_id": "a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4",
"session_id": "demo-session-001",

View file

@ -125,7 +125,7 @@ echo ""
# --- Example 8: Session pinning - second call (pinned result) ---
echo "--- 8. Session pinning - second call (same session, pinned) ---"
echo " Notice: same model returned with \"pinned\": true, routing was skipped"
echo " Notice: same anchor model returned with \"pinned\": true (warm session)"
echo ""
curl -s "$PLANO_URL/routing/v1/chat/completions" \
-H "Content-Type: application/json" \

View file

@ -0,0 +1,388 @@
# How-To: See Prompt Caching + Routing Budget in Action
A hands-on guide for running Plano's automatic prompt caching and the routing budget locally, and for measuring the win in evals/benchmarks (e.g. on DigitalOcean models).
There are two independent behaviors to observe:
1. **Automatic prompt caching** — staying on one model keeps the stable prefix
warm, so per-turn input cost drops sharply across a multi-turn conversation.
2. **Routing budget** — the router still runs every turn, but when it proposes a
*different* model while the session's cache is plausibly warm, Plano only switches
while the session's cumulative switch spend stays within `max_overhead_pct`% of what
staying put would have cost. This is a routing concern and works whether or not
prompt caching is on.
---
## 1. Prerequisites
- Plano CLI installed: `pip install planoai` (or `uv sync` from `cli/` for a dev build).
- Provider credentials as env vars, e.g.:
- `export DIGITALOCEAN_API_KEY=...` (DO SI)
- `export OPENAI_API_KEY=...`, `export ANTHROPIC_API_KEY=...` (if comparing)
- `curl` + `jq` for poking the endpoint.
---
## 2. Configuration
Start from `[config.yaml](config.yaml)` in this folder. The parts that matter:
```yaml
# Per-model pricing is REQUIRED for the routing budget — the switch cost math needs
# each model's input and cached-input rates.
model_metrics_sources:
- type: cost
provider: models.dev # publishes real cache_read rates
refresh_interval: 86400
prompt_caching:
enabled: true # automatic caching + session affinity (separate concern)
routing:
routing_budget: # no default — presence turns it on
max_overhead_pct: 20 # bill at most 20% above never-switching
# replenish_on_rebind: true # reset running totals when a cold session re-binds
# cache_read_discount: 0.1 # fallback when a feed omits cache_read
```
The routing budget lives under `routing` and is independent of prompt caching — it
applies whether or not `prompt_caching.enabled` is set.
### DigitalOcean variant
Address DO GenAI models with the `digitalocean/` prefix and point the cost feed
at the DO catalog (or keep `models.dev`, which publishes cached-read rates the
DO catalog doesn't):
```yaml
model_providers:
- model: digitalocean/anthropic-claude-4.6-sonnet
access_key: $DIGITALOCEAN_API_KEY
default: true
- model: digitalocean/openai-gpt-4o
access_key: $DIGITALOCEAN_API_KEY
model_metrics_sources:
- type: cost
provider: digitalocean # DO catalog
refresh_interval: 86400
```
> The DO catalog does not publish a cached-read rate, so for DO-only setups the
> gate falls back to `input_rate × cache_read_discount`. For exact cached rates,
> add a `models.dev` cost source instead.
---
## 3. Run it
```bash
# From this directory. --with-tracing starts a local OTLP collector on :4317.
planoai up config.yaml --with-tracing
# Tail logs (cache injections, pin events, switch decisions)
planoai logs --follow
# Stop
planoai down
```
The model listener comes up on **:12000** (per `config.yaml`).
---
## 4. See caching in action (single model)
Send the same large system prompt across several turns. With caching enabled,
Plano derives an implicit session from the stable prefix and pins the model, so
turns 2+ read the prefix from the provider cache.
```bash
curl -s localhost:12000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "digitalocean/anthropic-claude-4.6-sonnet",
"messages": [
{"role": "system", "content": "you are an intelligent agent"},
{"role": "user", "content": "Scaffold the service"}
]
}' | jq '.usage'
```
Watch `usage.prompt_tokens_details.cached_tokens` climb from 0 on turn 1 to
(nearly) the full prefix on later turns, and the billed cost fall accordingly —
this is exactly the ~4× per-turn drop in the caching-ON vs -OFF comparison.
---
## 5. See the routing budget in action (model switch)
The budget is consulted only when the router proposes a model that differs from
the session's warm anchor. Warmth is inferred from how long ago the session was
last used vs. the provider's cache window (no per-call cache-hit signal needed).
To observe it:
- **Vetoed switch (paid, over cap):** with a warm session on an expensive model
and a large context, a switch to a pricier candidate would push the session's total
switch spend past `max_overhead_pct`% of its never-switch baseline → Plano **retains**
the anchor.
- **Paid switch (within cap):** the same switch while the spend still fits under the
cap → Plano **switches** and adds `switch_cost` to the session's cumulative spend.
- **Free switch (cheaper candidate):** a candidate whose *uncached* input rate
undercuts the anchor's *cached* rate → switch cost ≤ 0 → Plano **switches** for free
(the spend is not reduced).
- **Cold session:** the session went idle past the provider cache window → treated
as cold → the router's pick is dispatched with no penalty (and the running totals
reset on `replenish_on_rebind`).
Each decision is emitted to metrics and traces (below) with a `reason` label
(`same_anchor | free | within_cap | over_cap | no_pricing`).
---
## 6. Run it as a routing decision (no proxying)
Everything above sends the full request through Plano, which then calls the
upstream model itself. There's a second entry point that only makes the
*decision* — same session lookup, same warmth inference, same routing budget —
without ever calling an LLM or seeing a response: `/routing` + the same
API path, on the same host:port as the model listener.
This is for callers who want to make the actual upstream call themselves (or
need it embedded in a broader pipeline, e.g. an intelligent-routing layer)
but still want Plano's cache-aware pick and fallback order.
Send a normal request with a **system prompt** and a user message — no affinity
header. Plano derives the session key implicitly from
`hash(system + tools + first user message)`, so you can watch the session pin
and go warm across turns (the zero-config path from §4, now visible on the
decision endpoint via `session_id` / `pinned`). Send `openai/gpt-4o-mini` as
`model`; the router picks the real model from the *message content*.
**Turn 1 — pin the session** with a generation prompt:
```bash
curl -s localhost:12000/routing/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model": "openai/gpt-4o-mini", "messages": [
{"role": "system", "content": "You are a senior Rust engineer."},
{"role": "user", "content": "Write a Rust function that reverses a linked list."}
]}' | jq '{model: .models[0], session_id, pinned}'
```
```json
{
"model": "anthropic/claude-sonnet-4-6",
"session_id": "implicit:8e76b367cc3a4336",
"pinned": false
}
```
The router classified this as `code generation``anthropic/claude-sonnet-4-6`.
`session_id` is the implicit key Plano derived from `system + tools + first
user message` (deterministic — you'll get the same hash for these exact
payloads), and `pinned` is `false` because this call *creates* the binding.
**Turn 2 — same system prompt + same first message**, one turn later, within ~5 min:
```bash
curl -s localhost:12000/routing/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model": "openai/gpt-4o-mini", "messages": [
{"role": "system", "content": "You are a senior Rust engineer."},
{"role": "user", "content": "Write a Rust function that reverses a linked list."},
{"role": "assistant", "content": "Here is an idiomatic in-place reversal for a singly linked list:\n\n```rust\ntype Link = Option<Box<Node>>;\n\nstruct Node {\n val: i32,\n next: Link,\n}\n\nfn reverse(mut head: Link) -> Link {\n let mut prev: Link = None;\n while let Some(mut node) = head {\n head = node.next.take();\n node.next = prev;\n prev = Some(node);\n }\n prev\n}\n```\n\nIt walks the list once, moving the next pointer of each node to its predecessor."},
{"role": "user", "content": "Now explain its time complexity in plain English — no code."}
]}' | jq '{model: .models[0], session_id, pinned}'
```
```json
{
"model": "anthropic/claude-sonnet-4-6",
"session_id": "implicit:8e76b367cc3a4336",
"pinned": true
}
```
The `session_id` is **identical** to turn 1 — the head of the prompt
(`system` + first user message) didn't change, so the implicit key stays stable
as history grows — and `pinned` is now `true`: the session is warm and stuck to
its anchor. **That's the pinning.**
**Now the budget:** here the router *did* read turn 2 as a different route
(`code understanding``openai/gpt-4o`), so with the anchor warm on
`claude-sonnet-4-6` the budget evaluated the switch — and vetoed it. The
brightstaff log shows exactly why:
```text
switch vetoed — would exceed session overhead cap, retaining anchor
anchor=anthropic/claude-sonnet-4-6 candidate=openai/gpt-4o
switch_cost_in_usd=3.96e-5 switch_spend_in_usd=0.0 overhead_ceiling_in_usd=1.08e-6
```
The switch would cost ~$3.96e-5 to re-read the context on `gpt-4o`, but only
~$1.08e-6 of overhead was affordable (`max_overhead_pct`% of the still-tiny
one-turn baseline) — so `.models[0]` stays `anthropic/claude-sonnet-4-6`. Confirm
it with the metric:
```bash
curl -s localhost:9092/metrics | grep session_switch_decisions
```
```text
brightstaff_session_switch_decisions_total{decision="retained",reason="over_cap"} 1
```
To see the **other** side, remove the `routing_budget` block (or set
`max_overhead_pct` very high), restart, and repeat — turn 2 now returns
`"model": "openai/gpt-4o"` and the metric reads `decision="allowed",reason="free"`.
That before/after — same calls, one config line — is the whole point: the
router's quality pick wins *unless* the budget says the warm cache it burns
isn't worth it.
> **If you see `same_anchor`,** the router classified both turns the same way,
> so no switch was proposed — inherent to appended conversations, since the
> router weighs the whole thread. To force `candidate ≠ anchor`
> deterministically, pin the session with an explicit header instead
> (`-H 'X-Model-Affinity: budget-demo'`) and send two *standalone* one-line
> prompts that each route to a different model (an "explain this code" prompt,
> then a "write a function" prompt). The budget behaves identically regardless
> of how the session key was derived — `route()` doesn't branch on it.
### Interoperability with the full-proxy path
Because this endpoint **shares the same session cache and the same
`session_router::route()` logic** as the full-proxy path, the two are fully
interoperable: a session pinned via `/routing` is honored by a later
`/v1/chat/completions` call (and vice versa), including the exact same
`max_overhead_pct` gating. This is also why warmth here is inferred purely
from idle-time vs. the provider's cache window rather than a cache-hit signal
— this path never has a provider response to read one from.
---
## 7. Observability (for evals & benchmarks)
**Prometheus metrics** — brightstaff exposes `/metrics` on **:9092**
(Envoy admin/stats on **:9901/stats**):
| Metric | What it tells you |
| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `brightstaff_session_switch_decisions_total{decision="allowed"|"retained",reason}` | How often the budget let a switch through vs. vetoed it, and why |
| `brightstaff_prompt_cache_requests_total{provider,model,outcome="hit"|"miss"}` | Real provider cache hit rate |
| `brightstaff_session_cache_events_total{outcome}` | Session binding lookups/stores |
```bash
curl -s localhost:9092/metrics | grep -E 'session_switch_decisions|prompt_cache_requests'
```
**Traces** — run with `--with-tracing` and inspect the routing span per request:
- `plano.cache.warm` — whether the session's cache was considered warm this turn
- `plano.cache.idle_ms` — how long since the session was last used
- `plano.switch.cost_in_usd` — actual input-token cost of the proposed switch (output excluded)
- `plano.switch.candidate_warm_tokens` — context the candidate still has cached from an earlier visit this session (a return to a warm model re-reads only the delta, so its `cost_in_usd` is far lower than a full re-ingest)
- `plano.switch.overhead_ceiling_in_usd` — overhead ceiling (`max_overhead_pct`% x baseline) when the switch was evaluated
- `plano.switch.decision``allowed` or `retained`
- `plano.session.overhead_pct` — cumulative switching overhead consumed, as a % of the never-switch baseline (compare directly to `max_overhead_pct`)
- `plano.session.switch_spend_in_usd` — cumulative $ actually spent on switches this session
- `plano.session.baseline_in_usd` — cumulative $ staying on the anchor would have cost (the denominator)
- `plano.session.switches` — switches taken so far this session
- `plano.session.total_cost_in_usd` — cumulative *actual* conversation cost (input +
output), priced from the catalog and refined from real usage each turn (reflects cost
through the previous turn, since this turn isn't billed yet at decision time)
- `plano.switch.counterfactual_route` — on a `retained` decision, the route the gate
*would* have taken had the switch been allowed (only when `record_counterfactual: true`)
- `plano.session_id`, `plano.route.name`
Per-request cost also lands on each `plano(llm)` span (sum by `plano.session_id` for the
conversation total, or read `plano.session.total_cost_in_usd` off the routing span):
- `llm.usage.input_cost_usd` — uncached input at the input rate, cached reads at the
cached rate, cache creation at the plain input rate
- `llm.usage.output_cost_usd` — completion tokens x output rate
- `llm.usage.total_cost_usd` — input + output
**Grafana** — a ready dashboard + compose live in `config/grafana/`
(`docker compose up` there, using `prometheus_scrape.yaml`).
---
## 8. A/B methodology (baseline vs treatment)
The cleanest benchmark is same-workload, caching off vs on — the exact shape of
the caching-ON/OFF comparison:
- **Baseline (no caching):** send requests with header `X-Plano-Cache: off`
(disables implicit pinning + marker injection per request), or run with
`prompt_caching.enabled: false`.
- **Treatment (caching on):** default config in this folder.
Compare, over an identical multi-turn eval set:
- total `prompt_tokens` billed at the uncached vs cached rate,
- `cached_tokens` ratio (cache hit rate),
- total USD cost,
- and — for routing-heavy workloads — `session_switch_decisions_total` and the
per-request `plano.switch.*` attributes to confirm switches happen only when
affordable.
```bash
# Baseline call (caching bypassed)
curl -s localhost:12000/v1/chat/completions \
-H 'Content-Type: application/json' \
-H 'X-Plano-Cache: off' \
-d '{ ... }' | jq '.usage'
```
---
## 9. Knobs to sweep
| Setting | Effect |
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
| `routing.routing_budget.max_overhead_pct` | Switching overhead cap as a % of never-switching (higher = quality-first, more switching) |
| `routing.routing_budget.replenish_on_rebind` | Reset the running baseline/spend totals when a cold session re-binds |
| `routing.routing_budget.cache_read_discount` | Assumed cached rate when a feed omits `cache_read` (DO fallback) |
| `routing.routing_budget.record_counterfactual` | Emit `plano.switch.counterfactual_route` on vetoed switches (the road not taken)|
| `prompt_caching.session_ttl_seconds` | Session binding GC lifetime |
| `prompt_caching.min_prefix_tokens` | Minimum stable-prefix size before markers are injected |
| Header `X-Model-Affinity: <id>` | Explicit session key (overrides the implicit prefix hash) |
| Header `X-Plano-Cache: off` | Per-request bypass for baseline runs |
---
## Notes
- Caching **never** changes which model routing selects — the router still makes
the quality call; the overhead cap only vetoes a switch that the session can't afford.
- The routing budget is independent of prompt caching (it lives under `routing`) and
is fully opt-in with **no baked-in cap**: configuring it without a `max_overhead_pct`
(or without a cost source) fails startup with a clear message.

View file

@ -0,0 +1,38 @@
# Routing Budget
Preference-based routing with a cumulative per-session **routing budget** that
protects warm provider caches, plus automatic prompt caching. The budget is a
routing concern configured under `routing` — independent of prompt caching.
## The problem
Provider prompt caches are per-model. When intelligent routing moves a
conversation to a different model, the new model re-ingests the full context at
its **uncached** input rate. In input-heavy, append-only workloads (coding
agents especially), a nominally cheaper model can end up more expensive than
the cached rate you abandoned.
## What the routing budget does
The router runs every turn (routing stays cache-blind). When it proposes a model
that differs from the session's warm anchor, Plano computes the **actual
input-token cost** of abandoning the anchor's cache and only allows the switch
while the session's cumulative switch spend stays within `max_overhead_pct`% of
what never-switching would have cost — otherwise it retains the warm anchor. The
promise: the conversation bills at most `max_overhead_pct`% above never-switching.
Quality and cost stay separate — the router still picks the best model; the
budget only vetoes switches the session can't afford. Prompt caching
(`prompt_caching.enabled`) is a separate, optional concern that keeps the
upstream cache warm and injects provider cache-control markers.
## Run
```bash
planoai up config.yaml
```
See [config.yaml](config.yaml) for the annotated configuration, and
**[GUIDE.md](GUIDE.md)** for the full hands-on walkthrough — running it as a
routing decision, watching a switch get vetoed vs. allowed, the switch-cost
math, and all the metrics and trace attributes for evals.

View file

@ -0,0 +1,77 @@
version: v0.3.0
listeners:
- type: model
name: model_listener
port: 12000
model_providers:
- model: openai/gpt-4o-mini
access_key: $OPENAI_API_KEY
default: true
- model: openai/gpt-4o
access_key: $OPENAI_API_KEY
routing_preferences:
- name: code understanding
description: understand and explain existing code snippets, functions, or libraries
- model: anthropic/claude-sonnet-4-6
access_key: $ANTHROPIC_API_KEY
routing_preferences:
- name: code generation
description: generating new code snippets, functions, or boilerplate based on user prompts or requirements
# Per-model pricing is required for the routing budget: the switch-cost
# calculation needs each model's input and cached-input rates.
model_metrics_sources:
- type: cost
provider: models.dev
refresh_interval: 86400
# Automatic prompt caching (opt-in). Keeps a conversation pinned to the same
# model so the upstream provider's prompt cache stays warm across turns, and
# auto-injects cache-control markers where the provider needs them. This is a
# separate concern from the routing budget below.
prompt_caching:
enabled: true
routing:
# Per-session cost gate on model switching. Independent of prompt caching: it
# applies whenever configured, and presence of this block turns it on. The
# default posture is to stick to the model a session is warm on. When routing
# proposes a *different* model while that session's provider cache is plausibly
# still warm (inferred from how long ago the session was last used vs. the
# provider's cache window), the actual input-token cost of abandoning the cache:
#
# switch_cost = context_tokens x (candidate_uncached_input - anchor_cached_input)
#
# (output cost deliberately excluded) accrues into the session's cumulative switch
# spend. A paid switch is allowed only while that spend stays within max_overhead_pct
# percent of the session's running never-switch baseline (what staying on the anchor
# would have cost); an outright-cheaper switch is free but never reduces the spend.
# The cap is yours to define -- Plano never invents one, and startup fails without it
# (or without a cost source).
routing_budget:
# Cap on cumulative switching overhead, as a percentage of what the session would
# have cost by never switching (a whole number: 20 = 20%). The promise is "this
# conversation bills at most 20% above never-switching." 0 means "never pay to
# switch"; larger values buy more switches before sticking. Typical range 10-30.
max_overhead_pct: 20
# Reset the running baseline/spend totals when a cold session re-binds. Default true.
# replenish_on_rebind: true
# Fallback used only when the pricing feed doesn't publish a cached-read rate for
# a model: cached_rate = input_rate x cache_read_discount. Default 0.1.
# cache_read_discount: 0.1
# Record the route the gate WOULD have taken when it vetoes a switch, as the
# `plano.switch.counterfactual_route` trace attribute. Telemetry only -- the
# counterfactual model is never dispatched. Handy for evals that want to
# measure the road not taken. Default false.
record_counterfactual: true
tracing:
random_sampling: 100

View file

@ -0,0 +1,66 @@
#!/bin/bash
set -e
# Routing Budget demo — drives the /routing decision endpoint to show:
# 1. implicit session pinning (same session across turns, going warm)
# 2. the routing budget vetoing an unaffordable model switch
#
# Prereqs: `planoai up config.yaml` running, plus `curl` and `jq`.
# See GUIDE.md for the full walkthrough and how to flip the veto into an allow.
PLANO_URL="${PLANO_URL:-http://localhost:12000}"
METRICS_URL="${METRICS_URL:-http://localhost:9092}"
echo "=== Routing Budget Demo ==="
echo ""
echo "Uses the /routing/v1/chat/completions decision endpoint (no LLM call)."
echo "Watch session_id stay stable and pinned flip false -> true, then watch"
echo "the budget retain the warm anchor instead of following the router."
echo ""
# --- Turn 1: pin the session (a code-generation prompt) ---
echo "--- 1. Turn 1: pin the session (creates the binding) ---"
echo ""
curl -s "$PLANO_URL/routing/v1/chat/completions" \
-H 'Content-Type: application/json' \
-d '{"model": "openai/gpt-4o-mini", "messages": [
{"role": "system", "content": "You are a senior Rust engineer."},
{"role": "user", "content": "Write a Rust function that reverses a linked list."}
]}' | jq '{model: .models[0], session_id, pinned}'
echo ""
echo " Expect: model=anthropic/claude-sonnet-4-6, an implicit:… session_id, pinned=false"
echo ""
# --- Turn 2: same system prompt + same first message, one turn later ---
echo "--- 2. Turn 2: same session, warm, router proposes a different model ---"
echo ""
curl -s "$PLANO_URL/routing/v1/chat/completions" \
-H 'Content-Type: application/json' \
-d '{"model": "openai/gpt-4o-mini", "messages": [
{"role": "system", "content": "You are a senior Rust engineer."},
{"role": "user", "content": "Write a Rust function that reverses a linked list."},
{"role": "assistant", "content": "Here is an idiomatic in-place reversal for a singly linked list:\n\n```rust\ntype Link = Option<Box<Node>>;\n\nstruct Node {\n val: i32,\n next: Link,\n}\n\nfn reverse(mut head: Link) -> Link {\n let mut prev: Link = None;\n while let Some(mut node) = head {\n head = node.next.take();\n node.next = prev;\n prev = Some(node);\n }\n prev\n}\n```\n\nIt walks the list once, moving the next pointer of each node to its predecessor."},
{"role": "user", "content": "Now explain its time complexity in plain English — no code."}
]}' | jq '{model: .models[0], session_id, pinned}'
echo ""
echo " Expect: SAME session_id as turn 1, pinned=true. If the router proposed"
echo " openai/gpt-4o (code understanding), the budget vetoed the switch and"
echo " model stays anthropic/claude-sonnet-4-6 (the warm anchor)."
echo ""
# --- Switch decisions metric ---
echo "--- 3. Switch decisions (why the budget decided what it did) ---"
echo ""
curl -s "$METRICS_URL/metrics" | grep session_switch_decisions || true
echo ""
echo " over_cap = switch vetoed, anchor retained"
echo " free = cheaper/affordable switch allowed"
echo " same_anchor = router did not propose a switch this turn"
echo ""
echo "=== Demo Complete ==="
echo ""
echo "To see the switch ALLOWED instead of vetoed: comment out the routing_budget"
echo "block in config.yaml (or raise max_overhead_pct), then 'planoai down &&"
echo "planoai up config.yaml' and re-run — turn 2 will follow the router to"
echo "openai/gpt-4o. See GUIDE.md for details."