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, /// 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, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Routing { pub llm_provider: Option, pub model: Option, pub session_ttl_seconds: Option, pub session_max_entries: Option, pub session_cache: Option, /// Cost gate on model switching within a session. Self-sufficient and independent /// of prompt caching: presence of this block turns it on, implicit sessions are /// derived on its own, and warm anchors are always priced at cached rates — /// `prompt_caching` only controls marker injection and affinity-without-budget. pub routing_budget: Option, } #[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, pub tool: Option, pub url: String, #[serde(rename = "type")] pub agent_type: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentFilterChain { pub id: String, pub default: Option, pub description: Option, pub input_filters: Option>, } /// 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, pub agents: HashMap, } 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, pub output: Option, } 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, pub agents: Option>, pub input_filters: Option>, pub output_filters: Option>, pub port: u16, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StateStorageConfig { #[serde(rename = "type")] pub storage_type: StateStorageType, pub connection_string: Option, } #[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 where D: Deserializer<'de>, { Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TopLevelRoutingPreference { pub name: String, pub description: String, pub models: Vec, #[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, pub refresh_interval: Option, /// 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>, } #[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, } #[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>, pub model_providers: Vec, pub model_aliases: Option>, pub overrides: Option, pub routing: Option, /// 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, pub system_prompt: Option, pub prompt_guards: Option, pub prompt_targets: Option>, pub error_target: Option, pub ratelimits: Option>, pub tracing: Option, pub mode: Option, pub agents: Option>, pub filters: Option>, pub listeners: Vec, pub state_storage: Option, pub routing_preferences: Option>, pub model_metrics_sources: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Overrides { pub prompt_target_intent_matching_threshold: Option, pub optimize_context_window: Option, pub use_agent_orchestrator: Option, pub llm_routing_model: Option, pub agent_orchestration_model: Option, pub orchestrator_model_context_length: Option, pub disable_signals: Option, } /// 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, /// Auto-inject provider cache-control markers (e.g. Anthropic `cache_control`). /// Defaults to `true` when caching is enabled. pub inject_cache_control: Option, /// Minimum estimated prefix tokens before a cache breakpoint is injected. pub min_prefix_tokens: Option, /// Session pin TTL; falls back to `routing.session_ttl_seconds` when unset. pub session_ttl_seconds: Option, } /// 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 10–30. 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, /// 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 { 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, 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, } 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 { 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 { config .map(PromptCaching::resolve) .transpose() .map(Option::unwrap_or_default) } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Tracing { pub sampling_rate: Option, pub trace_arch_internal: Option, pub random_sampling: Option, pub opentracing_grpc_endpoint: Option, pub span_attributes: Option, /// 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>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SpanAttributes { pub header_prefixes: Option>, #[serde(rename = "static")] pub static_attributes: Option>, } /// 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, /// 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, } #[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, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct PromptGuards { pub input_guards: HashMap, } 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, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OnExceptionDetails { pub forward_to_error_target: Option, pub error_handler: Option, pub message: Option, } #[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, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct Header { pub key: String, pub value: Option, } #[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, } #[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 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(&self, serializer: S) -> std::result::Result 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, pub model: Option, pub default: Option, pub stream: Option, pub endpoint: Option, pub port: Option, pub rate_limits: Option, pub usage: Option, pub cluster_name: Option, pub base_url_path_prefix: Option, pub internal: Option, pub passthrough_auth: Option, pub headers: Option>, } pub trait IntoModels { fn into_models(self) -> Models; } impl IntoModels for Vec { 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, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Parameter { pub name: String, #[serde(rename = "type")] pub parameter_type: Option, pub description: String, pub required: Option, #[serde(rename = "enum")] pub enum_values: Option>, pub default: Option, pub in_path: Option, pub format: Option, } #[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, #[serde(rename = "http_method")] pub method: Option, pub http_headers: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PromptTarget { pub name: String, pub default: Option, pub description: String, pub endpoint: Option, pub parameters: Option>, pub system_prompt: Option, pub auto_llm_dispatch_on_response: Option, } // convert PromptTarget to ChatCompletionTool impl From<&PromptTarget> for ChatCompletionTool { fn from(val: &PromptTarget) -> Self { let properties: HashMap = match val.parameters { Some(ref entities) => { let mut properties: HashMap = 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 = 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 = 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 = 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); } } } }