plano/crates/common/src/configuration.rs
Musa 844f08bda7
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>
2026-07-20 17:53:34 -07:00

1254 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use hermesllm::apis::openai::{ModelDetail, ModelObject, Models};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
use std::fmt::Display;
use crate::api::open_ai::{
ChatCompletionTool, FunctionDefinition, FunctionParameter, FunctionParameters, ParameterType,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum SessionCacheType {
#[default]
Memory,
Redis,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionCacheConfig {
#[serde(rename = "type", default)]
pub cache_type: SessionCacheType,
/// Redis URL, e.g. `redis://localhost:6379`. Required when `type` is `redis`.
pub url: Option<String>,
/// 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}`.
pub tenant_header: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Routing {
pub llm_provider: Option<String>,
pub model: Option<String>,
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)]
pub struct ModelAlias {
pub target: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Agent {
pub id: String,
pub transport: Option<String>,
pub tool: Option<String>,
pub url: String,
#[serde(rename = "type")]
pub agent_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentFilterChain {
pub id: String,
pub default: Option<bool>,
pub description: Option<String>,
pub input_filters: Option<Vec<String>>,
}
/// A filter chain with its agent references resolved to concrete Agent objects.
/// Bundles the ordered filter IDs with the agent lookup map so they stay in sync.
#[derive(Debug, Clone, Default)]
pub struct ResolvedFilterChain {
pub filter_ids: Vec<String>,
pub agents: HashMap<String, Agent>,
}
impl ResolvedFilterChain {
pub fn is_empty(&self) -> bool {
self.filter_ids.is_empty()
}
pub fn to_agent_filter_chain(&self, id: &str) -> AgentFilterChain {
AgentFilterChain {
id: id.to_string(),
default: None,
description: None,
input_filters: Some(self.filter_ids.clone()),
}
}
}
/// Holds resolved input and output filter chains for a model listener.
#[derive(Debug, Clone, Default)]
pub struct FilterPipeline {
pub input: Option<ResolvedFilterChain>,
pub output: Option<ResolvedFilterChain>,
}
impl FilterPipeline {
pub fn has_input_filters(&self) -> bool {
self.input.as_ref().is_some_and(|c| !c.is_empty())
}
pub fn has_output_filters(&self) -> bool {
self.output.as_ref().is_some_and(|c| !c.is_empty())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ListenerType {
Model,
Agent,
Prompt,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Listener {
#[serde(rename = "type")]
pub listener_type: ListenerType,
pub name: String,
pub router: Option<String>,
pub agents: Option<Vec<AgentFilterChain>>,
pub input_filters: Option<Vec<String>>,
pub output_filters: Option<Vec<String>>,
pub port: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateStorageConfig {
#[serde(rename = "type")]
pub storage_type: StateStorageType,
pub connection_string: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum StateStorageType {
Memory,
Postgres,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SelectionPreference {
Cheapest,
Fastest,
/// Return models in the same order they were defined — no reordering.
#[default]
#[serde(alias = "")]
None,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SelectionPolicy {
#[serde(default, deserialize_with = "deserialize_selection_preference")]
pub prefer: SelectionPreference,
}
fn deserialize_selection_preference<'de, D>(
deserializer: D,
) -> Result<SelectionPreference, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<SelectionPreference>::deserialize(deserializer)?.unwrap_or_default())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopLevelRoutingPreference {
pub name: String,
pub description: String,
pub models: Vec<String>,
#[serde(default)]
pub selection_policy: SelectionPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MetricsSource {
Cost(CostMetricsConfig),
Latency(LatencyMetricsConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostMetricsConfig {
pub provider: CostProvider,
/// Optional override for the pricing catalog endpoint. When omitted, a
/// sensible default is used per provider.
pub url: Option<String>,
pub refresh_interval: Option<u64>,
/// Map catalog keys to Plano model names used in `routing_preferences`.
/// DigitalOcean keys look like `lowercase(creator)/model_id`; models.dev
/// keys look like `creator/model_id`.
/// Example: `openai/openai-gpt-oss-120b: openai/gpt-4o`
pub model_aliases: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CostProvider {
Digitalocean,
#[serde(rename = "models.dev")]
ModelsDev,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LatencyMetricsConfig {
pub provider: LatencyProvider,
pub url: String,
pub query: String,
pub refresh_interval: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LatencyProvider {
Prometheus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Configuration {
pub version: String,
pub endpoints: Option<HashMap<String, Endpoint>>,
pub model_providers: Vec<LlmProvider>,
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>>,
pub error_target: Option<ErrorTargetDetail>,
pub ratelimits: Option<Vec<Ratelimit>>,
pub tracing: Option<Tracing>,
pub mode: Option<GatewayMode>,
pub agents: Option<Vec<Agent>>,
pub filters: Option<Vec<Agent>>,
pub listeners: Vec<Listener>,
pub state_storage: Option<StateStorageConfig>,
pub routing_preferences: Option<Vec<TopLevelRoutingPreference>>,
pub model_metrics_sources: Option<Vec<MetricsSource>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Overrides {
pub prompt_target_intent_matching_threshold: Option<f64>,
pub optimize_context_window: Option<bool>,
pub use_agent_orchestrator: Option<bool>,
pub llm_routing_model: Option<String>,
pub agent_orchestration_model: Option<String>,
pub orchestrator_model_context_length: Option<usize>,
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>,
pub trace_arch_internal: Option<bool>,
pub random_sampling: Option<u32>,
pub opentracing_grpc_endpoint: Option<String>,
pub span_attributes: Option<SpanAttributes>,
/// Provider-agnostic telemetry export destinations. Each entry is tagged by
/// its `type` (e.g. `posthog`) so new backends can be added without breaking
/// existing configs. LLM spans are translated into each backend's native
/// event format and streamed in addition to any `opentracing_grpc_endpoint`.
pub exporters: Option<Vec<Exporter>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SpanAttributes {
pub header_prefixes: Option<Vec<String>>,
#[serde(rename = "static")]
pub static_attributes: Option<HashMap<String, String>>,
}
/// A telemetry export destination configured under `tracing.exporters`.
///
/// The list is provider-agnostic; each variant is internally tagged by its
/// `type` field (e.g. `type: posthog`). Additional backends (datadog, raw
/// otlp, ...) can be added as new variants without breaking existing configs.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Exporter {
/// PostHog AI observability. LLM spans are converted into PostHog
/// `$ai_generation` events and POSTed to the configured `url`.
Posthog(PosthogExporter),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PosthogExporter {
/// PostHog host, e.g. `https://us.i.posthog.com`. The `/batch/` capture
/// path is appended automatically.
pub url: String,
/// PostHog project API key (token). Supports `$ENV_VAR` expansion at render
/// time, e.g. `$POSTHOG_API_KEY`.
pub api_key: String,
/// Optional request header whose value is used as the PostHog `distinct_id`.
/// When unset (or the header is missing on a request) events are captured
/// anonymously.
pub distinct_id_header: Option<String>,
/// When true, include the truncated user message preview as `$ai_input`.
/// Defaults to `false` to avoid sending prompt content off-box.
pub capture_messages: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
pub enum GatewayMode {
#[serde(rename = "llm")]
Llm,
#[default]
#[serde(rename = "prompt")]
Prompt,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorTargetDetail {
pub endpoint: Option<EndpointDetails>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PromptGuards {
pub input_guards: HashMap<GuardType, GuardOptions>,
}
impl PromptGuards {
pub fn jailbreak_on_exception_message(&self) -> Option<&str> {
self.input_guards
.get(&GuardType::Jailbreak)?
.on_exception
.as_ref()?
.message
.as_ref()?
.as_str()
.into()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum GuardType {
#[serde(rename = "jailbreak")]
Jailbreak,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuardOptions {
pub on_exception: Option<OnExceptionDetails>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnExceptionDetails {
pub forward_to_error_target: Option<bool>,
pub error_handler: Option<String>,
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmRatelimit {
pub selector: LlmRatelimitSelector,
pub limit: Limit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmRatelimitSelector {
pub http_header: Option<RatelimitHeader>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct Header {
pub key: String,
pub value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ratelimit {
pub model: String,
pub selector: Header,
pub limit: Limit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Limit {
pub tokens: u32,
pub unit: TimeUnit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TimeUnit {
#[serde(rename = "second")]
Second,
#[serde(rename = "minute")]
Minute,
#[serde(rename = "hour")]
Hour,
#[serde(rename = "day")]
Day,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct RatelimitHeader {
pub name: String,
pub value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
//TODO: use enum for model, but if there is a new model, we need to update the code
pub struct EmbeddingProviver {
pub name: String,
pub model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum LlmProviderType {
#[serde(rename = "anthropic")]
Anthropic,
#[serde(rename = "deepseek")]
Deepseek,
#[serde(rename = "groq")]
Groq,
#[serde(rename = "mistral")]
Mistral,
#[serde(rename = "openai")]
OpenAI,
#[serde(rename = "xiaomi")]
Xiaomi,
#[serde(rename = "gemini")]
Gemini,
#[serde(rename = "xai")]
XAI,
#[serde(rename = "together_ai")]
TogetherAI,
#[serde(rename = "azure_openai")]
AzureOpenAI,
#[serde(rename = "ollama")]
Ollama,
#[serde(rename = "moonshotai")]
Moonshotai,
#[serde(rename = "zhipu")]
Zhipu,
#[serde(rename = "qwen")]
Qwen,
#[serde(rename = "amazon_bedrock")]
AmazonBedrock,
#[serde(rename = "plano")]
Plano,
#[serde(rename = "chatgpt")]
ChatGPT,
#[serde(rename = "digitalocean")]
DigitalOcean,
#[serde(rename = "vercel")]
Vercel,
#[serde(rename = "openrouter")]
OpenRouter,
#[serde(rename = "astraflow")]
Astraflow,
#[serde(rename = "astraflow_cn")]
AstraflowCN,
}
impl Display for LlmProviderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LlmProviderType::Anthropic => write!(f, "anthropic"),
LlmProviderType::Deepseek => write!(f, "deepseek"),
LlmProviderType::Groq => write!(f, "groq"),
LlmProviderType::Gemini => write!(f, "gemini"),
LlmProviderType::Mistral => write!(f, "mistral"),
LlmProviderType::OpenAI => write!(f, "openai"),
LlmProviderType::Xiaomi => write!(f, "xiaomi"),
LlmProviderType::XAI => write!(f, "xai"),
LlmProviderType::TogetherAI => write!(f, "together_ai"),
LlmProviderType::AzureOpenAI => write!(f, "azure_openai"),
LlmProviderType::Ollama => write!(f, "ollama"),
LlmProviderType::Moonshotai => write!(f, "moonshotai"),
LlmProviderType::Zhipu => write!(f, "zhipu"),
LlmProviderType::Qwen => write!(f, "qwen"),
LlmProviderType::AmazonBedrock => write!(f, "amazon_bedrock"),
LlmProviderType::Plano => write!(f, "plano"),
LlmProviderType::ChatGPT => write!(f, "chatgpt"),
LlmProviderType::DigitalOcean => write!(f, "digitalocean"),
LlmProviderType::Vercel => write!(f, "vercel"),
LlmProviderType::OpenRouter => write!(f, "openrouter"),
LlmProviderType::Astraflow => write!(f, "astraflow"),
LlmProviderType::AstraflowCN => write!(f, "astraflow_cn"),
}
}
}
impl LlmProviderType {
/// Get the ProviderId for this LlmProviderType
/// Used with the new function-based hermesllm API
pub fn to_provider_id(&self) -> hermesllm::ProviderId {
hermesllm::ProviderId::try_from(self.to_string().as_str())
.expect("LlmProviderType should always map to a valid ProviderId")
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct AgentUsagePreference {
pub model: String,
pub orchestration_preferences: Vec<OrchestrationPreference>,
}
/// OrchestrationPreference with custom serialization to always include default parameters.
/// The parameters field is always serialized as:
/// {"type": "object", "properties": {}, "required": []}
#[derive(Debug, Clone, Deserialize)]
pub struct OrchestrationPreference {
pub name: String,
pub description: String,
}
impl serde::Serialize for OrchestrationPreference {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("OrchestrationPreference", 3)?;
state.serialize_field("name", &self.name)?;
state.serialize_field("description", &self.description)?;
state.serialize_field(
"parameters",
&serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
)?;
state.end()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
//TODO: use enum for model, but if there is a new model, we need to update the code
pub struct LlmProvider {
pub name: String,
pub provider_interface: LlmProviderType,
pub access_key: Option<String>,
pub model: Option<String>,
pub default: Option<bool>,
pub stream: Option<bool>,
pub endpoint: Option<String>,
pub port: Option<u16>,
pub rate_limits: Option<LlmRatelimit>,
pub usage: Option<String>,
pub cluster_name: Option<String>,
pub base_url_path_prefix: Option<String>,
pub internal: Option<bool>,
pub passthrough_auth: Option<bool>,
pub headers: Option<HashMap<String, String>>,
}
pub trait IntoModels {
fn into_models(self) -> Models;
}
impl IntoModels for Vec<LlmProvider> {
fn into_models(self) -> Models {
let data = self
.iter()
.filter(|provider| provider.internal != Some(true))
.map(|provider| ModelDetail {
id: provider.name.clone(),
object: Some("model".to_string()),
created: 0,
owned_by: "system".to_string(),
})
.collect();
Models {
object: ModelObject::List,
data,
}
}
}
impl Default for LlmProvider {
fn default() -> Self {
Self {
name: "openai".to_string(),
provider_interface: LlmProviderType::OpenAI,
access_key: None,
model: None,
default: Some(true),
stream: Some(false),
endpoint: None,
port: None,
rate_limits: None,
usage: None,
cluster_name: None,
base_url_path_prefix: None,
internal: None,
passthrough_auth: None,
headers: None,
}
}
}
impl Display for LlmProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name)
}
}
impl LlmProvider {
/// Get the ProviderId for this LlmProvider
/// Used with the new function-based hermesllm API
pub fn to_provider_id(&self) -> hermesllm::ProviderId {
self.provider_interface.to_provider_id()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Endpoint {
pub endpoint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Parameter {
pub name: String,
#[serde(rename = "type")]
pub parameter_type: Option<String>,
pub description: String,
pub required: Option<bool>,
#[serde(rename = "enum")]
pub enum_values: Option<Vec<String>>,
pub default: Option<String>,
pub in_path: Option<bool>,
pub format: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
pub enum HttpMethod {
#[default]
#[serde(rename = "GET")]
Get,
#[serde(rename = "POST")]
Post,
}
impl Display for HttpMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpMethod::Get => write!(f, "GET"),
HttpMethod::Post => write!(f, "POST"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndpointDetails {
pub name: String,
pub path: Option<String>,
#[serde(rename = "http_method")]
pub method: Option<HttpMethod>,
pub http_headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptTarget {
pub name: String,
pub default: Option<bool>,
pub description: String,
pub endpoint: Option<EndpointDetails>,
pub parameters: Option<Vec<Parameter>>,
pub system_prompt: Option<String>,
pub auto_llm_dispatch_on_response: Option<bool>,
}
// convert PromptTarget to ChatCompletionTool
impl From<&PromptTarget> for ChatCompletionTool {
fn from(val: &PromptTarget) -> Self {
let properties: HashMap<String, FunctionParameter> = match val.parameters {
Some(ref entities) => {
let mut properties: HashMap<String, FunctionParameter> = HashMap::new();
for entity in entities.iter() {
let param = FunctionParameter {
parameter_type: ParameterType::from(
entity.parameter_type.clone().unwrap_or("str".to_string()),
),
description: entity.description.clone(),
required: entity.required,
enum_values: entity.enum_values.clone(),
default: entity.default.clone(),
format: entity.format.clone(),
};
properties.insert(entity.name.clone(), param);
}
properties
}
None => HashMap::new(),
};
ChatCompletionTool {
tool_type: crate::api::open_ai::ToolType::Function,
function: FunctionDefinition {
name: val.name.clone(),
description: val.description.clone(),
parameters: FunctionParameters { properties },
},
}
}
}
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;
use std::fs;
use super::{
EffectivePromptCaching, EffectiveRoutingBudget, IntoModels, LlmProvider, LlmProviderType,
PromptCaching, RoutingBudget, DEFAULT_CACHE_READ_DISCOUNT, DEFAULT_MIN_PREFIX_TOKENS,
};
use crate::api::open_ai::ToolType;
#[test]
fn test_deserialize_configuration() {
let ref_config = fs::read_to_string(
"../../docs/source/resources/includes/plano_config_full_reference_rendered.yaml",
)
.expect("reference config file not found");
let config: super::Configuration = serde_yaml::from_str(&ref_config).unwrap();
assert_eq!(config.version, "v0.4.0");
if let Some(prompt_targets) = &config.prompt_targets {
assert!(
!prompt_targets.is_empty(),
"prompt_targets should not be empty if present"
);
}
if let Some(tracing) = config.tracing.as_ref() {
if let Some(sampling_rate) = tracing.sampling_rate {
assert_eq!(sampling_rate, 0.1);
}
}
let mode = config.mode.as_ref().unwrap_or(&super::GatewayMode::Prompt);
assert_eq!(*mode, super::GatewayMode::Prompt);
}
#[test]
fn test_tool_conversion() {
let ref_config = fs::read_to_string(
"../../docs/source/resources/includes/plano_config_full_reference_rendered.yaml",
)
.expect("reference config file not found");
let config: super::Configuration = serde_yaml::from_str(&ref_config).unwrap();
if let Some(prompt_targets) = &config.prompt_targets {
if let Some(prompt_target) = prompt_targets
.iter()
.find(|p| p.name == "reboot_network_device")
{
let chat_completion_tool: super::ChatCompletionTool = prompt_target.into();
assert_eq!(chat_completion_tool.tool_type, ToolType::Function);
assert_eq!(chat_completion_tool.function.name, "reboot_network_device");
assert_eq!(
chat_completion_tool.function.description,
"Reboot a specific network device"
);
assert_eq!(chat_completion_tool.function.parameters.properties.len(), 2);
assert!(chat_completion_tool
.function
.parameters
.properties
.contains_key("device_id"));
let device_id_param = chat_completion_tool
.function
.parameters
.properties
.get("device_id")
.unwrap();
assert_eq!(
device_id_param.parameter_type,
crate::api::open_ai::ParameterType::String
);
assert_eq!(
device_id_param.description,
"Identifier of the network device to reboot.".to_string()
);
assert_eq!(device_id_param.required, Some(true));
let confirmation_param = chat_completion_tool
.function
.parameters
.properties
.get("confirmation")
.unwrap();
assert_eq!(
confirmation_param.parameter_type,
crate::api::open_ai::ParameterType::Bool
);
}
}
}
#[test]
fn test_deserialize_models_dev_cost_source() {
let yaml = r#"
- type: cost
provider: models.dev
url: https://models.dev/api.json
refresh_interval: 3600
model_aliases:
openai/gpt-oss-120b: openai/gpt-4o
"#;
let sources: Vec<super::MetricsSource> = serde_yaml::from_str(yaml).unwrap();
assert_eq!(sources.len(), 1);
match &sources[0] {
super::MetricsSource::Cost(cfg) => {
assert!(matches!(cfg.provider, super::CostProvider::ModelsDev));
assert_eq!(cfg.url.as_deref(), Some("https://models.dev/api.json"));
assert_eq!(cfg.refresh_interval, Some(3600));
assert_eq!(
cfg.model_aliases
.as_ref()
.and_then(|m| m.get("openai/gpt-oss-120b"))
.map(String::as_str),
Some("openai/gpt-4o")
);
}
other => panic!("expected cost source, got {other:?}"),
}
}
#[test]
fn test_deserialize_digitalocean_cost_source_without_url() {
let yaml = r#"
- type: cost
provider: digitalocean
"#;
let sources: Vec<super::MetricsSource> = serde_yaml::from_str(yaml).unwrap();
match &sources[0] {
super::MetricsSource::Cost(cfg) => {
assert!(matches!(cfg.provider, super::CostProvider::Digitalocean));
assert_eq!(cfg.url, None);
}
other => panic!("expected cost source, got {other:?}"),
}
}
#[test]
fn test_into_models_filters_internal_providers() {
let providers = vec![
LlmProvider {
name: "openai-gpt4".to_string(),
provider_interface: LlmProviderType::OpenAI,
model: Some("gpt-4".to_string()),
internal: None,
..Default::default()
},
LlmProvider {
name: "plano-orchestrator".to_string(),
provider_interface: LlmProviderType::Plano,
model: Some("Plano-Orchestrator".to_string()),
internal: Some(true),
..Default::default()
},
];
let models = providers.into_models();
assert_eq!(models.data.len(), 1);
let model_ids: Vec<String> = models.data.iter().map(|m| m.id.clone()).collect();
assert!(model_ids.contains(&"openai-gpt4".to_string()));
assert!(!model_ids.contains(&"plano-orchestrator".to_string()));
}
#[test]
fn test_llm_provider_type_vercel_and_openrouter_roundtrip() {
// Regression: brightstaff used to reject `provider_interface: vercel`
// (and `openrouter`) because these variants were missing from
// `LlmProviderType`, causing `planoai up` with the synthesized default
// config to crash on startup.
for (yaml_value, expected) in [
("vercel", LlmProviderType::Vercel),
("openrouter", LlmProviderType::OpenRouter),
] {
let parsed: LlmProviderType =
serde_yaml::from_str(yaml_value).expect("variant should deserialize");
assert_eq!(parsed, expected);
assert_eq!(parsed.to_string(), yaml_value);
// to_provider_id() bridges into hermesllm; both providers must be
// recognized there as well or this panics.
let _ = parsed.to_provider_id();
}
}
#[test]
fn test_overrides_disable_signals_default_none() {
let overrides = super::Overrides::default();
assert_eq!(overrides.disable_signals, None);
}
#[test]
fn test_overrides_disable_signals_deserialize() {
let yaml = r#"
disable_signals: true
"#;
let overrides: super::Overrides = serde_yaml::from_str(yaml).unwrap();
assert_eq!(overrides.disable_signals, Some(true));
let yaml_false = r#"
disable_signals: false
"#;
let overrides: super::Overrides = serde_yaml::from_str(yaml_false).unwrap();
assert_eq!(overrides.disable_signals, Some(false));
let yaml_missing = "{}";
let overrides: super::Overrides = serde_yaml::from_str(yaml_missing).unwrap();
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#"
random_sampling: 100
exporters:
- type: posthog
url: https://us.i.posthog.com
api_key: phc_secret
distinct_id_header: x-user-id
capture_messages: true
"#;
let tracing: super::Tracing = serde_yaml::from_str(yaml).unwrap();
let exporters = tracing.exporters.expect("exporters should be parsed");
assert_eq!(exporters.len(), 1);
match &exporters[0] {
super::Exporter::Posthog(posthog) => {
assert_eq!(posthog.url, "https://us.i.posthog.com");
assert_eq!(posthog.api_key, "phc_secret");
assert_eq!(posthog.distinct_id_header.as_deref(), Some("x-user-id"));
assert_eq!(posthog.capture_messages, Some(true));
}
}
}
#[test]
fn test_tracing_posthog_exporter_minimal() {
let yaml = r#"
exporters:
- type: posthog
url: https://eu.i.posthog.com
api_key: phc_eu
"#;
let tracing: super::Tracing = serde_yaml::from_str(yaml).unwrap();
let exporters = tracing.exporters.unwrap();
match &exporters[0] {
super::Exporter::Posthog(posthog) => {
assert_eq!(posthog.url, "https://eu.i.posthog.com");
assert_eq!(posthog.distinct_id_header, None);
assert_eq!(posthog.capture_messages, None);
}
}
}
}