Skip to content

feat(routing): prompt caching, session stickiness, and cache-regret cost gate#982

Open
Spherrrical wants to merge 8 commits into
mainfrom
musa/cache-aware-routing
Open

feat(routing): prompt caching, session stickiness, and cache-regret cost gate#982
Spherrrical wants to merge 8 commits into
mainfrom
musa/cache-aware-routing

Conversation

@Spherrrical

@Spherrrical Spherrrical commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Intelligent routing and prompt caching pull against each other: routing wants freedom to move traffic across models, while provider caches are per-model and only pay off when a conversation stays put. Every reroute silently abandons a warm cache and re-bills the whole prompt at the uncached rate — so a "cheaper" model can end up more expensive. This PR makes the two work together, without letting caching hijack routing quality.

  • Automatic prompt caching (opt-in, zero-config). With no X-Model-Affinity header, Plano derives a stable session key from the prompt prefix (system + tools + first user message) so multi-turn conversations stay pinned and warm. Model-aware cache-control markers are auto-injected — Anthropic breakpoints or OpenAI content-part cache_control, idempotent and threshold-guarded — so OpenAI-shaped clients get caching on Anthropic-family upstreams. Caching never influences which model routing selects.
  • Session stickiness + cache-regret cost gate. Sticks to the pinned model by default. When a re-route is triggered (pin expired / prefix drift) and the previous model's cache is plausibly still warm, Plano weighs the input-cost regret of switching — context_tokens × (candidate_uncached_rate − previous_cached_rate) — against a developer-defined threshold, and either switches or retains the previous model. Input-only by design (no output-token gamble); negative regret always switches; drifted prefix means the cache is already cold, so the switch is free. Fully opt-in with no baked-in numbers (max_regret_usd or max_regret_pct_of_cached). The router still decides which model is better — the gate only vetoes a switch that isn't affordable.
  • Per-model pricing. Expose structured input + cached-read rates from the cost feed (models.dev cache_read, with a cache_read_discount fallback for feeds like DO that omit it) to drive the regret math; blended cost ranking is untouched.
  • Feedback loop. Response usage drives pin-after-hit, prefix-drift re-routing, and pin validation.
  • Telemetry. brightstaff_session_switch_decisions_total{decision}, pin-event and prompt-cache hit/miss counters, plus routing-span attributes plano.switch.regret_usd, plano.switch.threshold_usd, and plano.switch.decision — so a trace shows exactly why a request switched or stuck.
  • Optional KV-aware replica stickiness for self-hosted backends via endpoints.<name>.prefix_affinity (ring-hash on x-plano-prefix-hash), independent of prompt caching.

Sample config + walkthrough under demos/llm_routing/session_stickiness/.

Test plan

  • cargo test --lib (brightstaff + hermesllm + common) — pass
  • cargo clippy --all-targets --all-features -- -D warnings — clean
  • cd cli && uv run pytest — schema/template render
  • Manual: multi-turn session stays pinned with no header; an expensive re-route is vetoed by the gate while a cheap one is allowed; X-Plano-Cache: off disables per request

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.
# Conflicts:
#	crates/brightstaff/src/handlers/llm/mod.rs
#	crates/brightstaff/src/router/model_metrics.rs
#	crates/common/src/configuration.rs
@Spherrrical Spherrrical changed the title feat(routing): cache-aware routing and zero-config prompt caching feat(routing): prompt caching, session stickiness, and cache-regret cost gate Jul 7, 2026
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.
@nehcgs

nehcgs commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

@Spherrrical Thanks for pushing this PR!

  1. Does it only support OpenAI and Anthropic? How do we know if the prompt caching is enabled for each request from other model providers or server side?
  2. Refactor needed:
    • Too many files are changed. Please group session sticking logic in one place and prompt caching handling logic in another place to improve readability.
    • For this file crates/brightstaff/src/handlers/function_calling.rs, I'm curious why it's still here and being updated. Didn't we deprecate the function calling completely? We don't use Arch-Function any more, right?
    • Besides function calling, why is this file crates/brightstaff/src/session_cache/memory.rs also being updated? What does this memory cache refer to?
  3. Also, please update the summary of this PR as well.

…sive 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.
@Spherrrical

Copy link
Copy Markdown
Collaborator Author

@nehcgs

  1. Does it only support OpenAI and Anthropic? How do we know if the prompt caching is enabled for each request from other model providers or server side?

It's not limited to OpenAI + Anthropic. The strategy is resolved per (gateway × model family × upstream API) in cache_marker_strategy: OpenAI-family caches automatically (no markers) across OpenAI/Azure/etc.; Anthropic-family gets native Messages breakpoints or, behind OpenAI-compatible gateways (DigitalOcean, OpenRouter), content-part cache_control; unimplemented backends (Bedrock) are an honest None. Crucially, we don't assume caching worked, we read it back from each provider's own response usage (prompt_tokens_details.cached_tokens / cache_read_input_tokens), normalize it, and surface it per request as llm.usage.cached_input_tokens on the span plus brightstaff_prompt_cache_requests_total{provider,model,outcome=hit|miss}.

  • Too many files are changed. Please group session sticking logic in one place and prompt caching handling logic in another place to improve readability.

Done!

  • For this file crates/brightstaff/src/handlers/function_calling.rs, I'm curious why it's still here and being updated. Didn't we deprecate the function calling completely? We don't use Arch-Function any more, right?

The only change there is a one-line because the caching work added two fields to the shared Usage struct so every existing literal had to initialize them. It's not a revival of Arch-Function. The handler is technically still wired (/function_calling route), so if it's truly deprecated we can remove it in a separate cleanup PR rather than bundling that here.

  • Besides function calling, why is this file crates/brightstaff/src/session_cache/memory.rs also being updated? What does this memory cache refer to?

It changed because session stickiness introduced stale pins: get() now returns CacheLookup { route, is_stale } and entries linger for ttl × STALE_TTL_FACTOR so an expired pin can still be handed to the cache-regret gate as the "previous route." this one is core to the feature (Redis backend got the same change for parity).

  1. Also, please update the summary of this PR as well.

Done!

@salmanap

salmanap commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Intelligent routing and prompt caching pull against each other: routing wants freedom to move traffic across models, while provider caches are per-model and only pay off when a conversation stays put. Every reroute silently abandons a warm cache and re-bills the whole prompt at the uncached rate — so a "cheaper" model can end up more expensive. This PR makes the two work together, without letting caching hijack routing quality.

  • Automatic prompt caching (opt-in, zero-config). With no X-Model-Affinity header, Plano derives a stable session key from the prompt prefix (system + tools + first user message) so multi-turn conversations stay pinned and warm. Model-aware cache-control markers are auto-injected — Anthropic breakpoints or OpenAI content-part cache_control, idempotent and threshold-guarded — so OpenAI-shaped clients get caching on Anthropic-family upstreams. Caching never influences which model routing selects.

Why only the first user message in determining the prompt prefix? Separately, I don't follow this notion of "auto injected". That whole sentence is slop.

  • Session stickiness + cache-regret cost gate. Sticks to the pinned model by default. When a re-route is triggered (pin expired / prefix drift) and the previous model's cache is plausibly still warm, Plano weighs the input-cost regret of switching — context_tokens × (candidate_uncached_rate − previous_cached_rate) — against a developer-defined threshold, and either switches or retains the previous model. Input-only by design (no output-token gamble); negative regret always switches; drifted prefix means the cache is already cold, so the switch is free. Fully opt-in with no baked-in numbers (max_regret_usd or max_regret_pct_of_cached). The router still decides which model is better — the gate only vetoes a switch that isn't affordable.

How would we know that the cache is "plausibly" still warm? And I don't follow this whole regret cost gating math one bit. Not clear to me, how will it be clear to the developers building with plano.

  • Per-model pricing. Expose structured input + cached-read rates from the cost feed (models.dev cache_read, with a cache_read_discount fallback for feeds like DO that omit it) to drive the regret math; blended cost ranking is untouched.
  • Feedback loop. Response usage drives pin-after-hit, prefix-drift re-routing, and pin validation.
  • Telemetry. brightstaff_session_switch_decisions_total{decision}, pin-event and prompt-cache hit/miss counters, plus routing-span attributes plano.switch.regret_usd, plano.switch.threshold_usd, and plano.switch.decision — so a trace shows exactly why a request switched or stuck.
  • Optional KV-aware replica stickiness for self-hosted backends via endpoints.<name>.prefix_affinity (ring-hash on x-plano-prefix-hash), independent of prompt caching.

I am not sure what this means? how does this work? what does the developer have to do? Separately I am not sure how can you compute prefix trees unless you are storing the prompt prefix somewhere in a database

Please share the user experience in the config.yaml here. Don't link to the demo.

Sample config + walkthrough under demos/llm_routing/session_stickiness/.

Test plan

  • cargo test --lib (brightstaff + hermesllm + common) — pass
  • cargo clippy --all-targets --all-features -- -D warnings — clean
  • cd cli && uv run pytest — schema/template render
  • Manual: multi-turn session stays pinned with no header; an expensive re-route is vetoed by the gate while a cheap one is allowed; X-Plano-Cache: off disables per request

@salmanap salmanap self-requested a review July 8, 2026 00:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants