use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::{trace, warn};
use zentinel_common::budget::{
BudgetPeriod, CostAttributionConfig, ModelPricing, TokenBudgetConfig,
};
use crate::routes::*;
use super::helpers::{
get_bool_entry, get_first_arg_string, get_float_entry, get_int_entry, get_string_entry,
};
const RECOGNIZED_ROUTE_CHILDREN: &[&str] = &[
"matches",
"priority",
"upstream",
"static-files",
"api-schema",
"inference",
"filters",
"builtin-handler",
"cache",
"shadow",
"waf-enabled",
"websocket",
"websocket-inspection",
"fallback",
"policies",
"service-type",
];
pub fn parse_routes(node: &kdl::KdlNode) -> Result<Vec<RouteConfig>> {
trace!("Parsing routes configuration block");
let mut routes = Vec::new();
if let Some(children) = node.children() {
for child in children.nodes() {
if child.name().value() == "route" {
let id = get_first_arg_string(child).ok_or_else(|| {
anyhow::anyhow!("Route requires an ID argument, e.g., route \"api\" {{ ... }}")
})?;
trace!(route_id = %id, "Parsing route");
let matches = parse_match_conditions(child)?;
let priority = parse_priority(child);
let upstream = parse_upstream_ref(child);
let static_files = parse_static_file_config_opt(child)?;
let api_schema = parse_api_schema_config_opt(child)?;
let inference = parse_inference_config_opt(child)?;
let filters = parse_route_filter_refs(child)?;
let builtin_handler =
get_string_entry(child, "builtin-handler").and_then(|s| match s.as_str() {
"status" => Some(BuiltinHandler::Status),
"health" => Some(BuiltinHandler::Health),
"metrics" => Some(BuiltinHandler::Metrics),
"not-found" | "not_found" => Some(BuiltinHandler::NotFound),
"config" => Some(BuiltinHandler::Config),
"upstreams" => Some(BuiltinHandler::Upstreams),
"cache-purge" | "cache_purge" => Some(BuiltinHandler::CachePurge),
"cache-stats" | "cache_stats" => Some(BuiltinHandler::CacheStats),
_ => None,
});
let cache_config = parse_cache_config_opt(child)?;
let shadow = parse_shadow_config_opt(child)?;
let service_type = if static_files.is_some() {
ServiceType::Static
} else if builtin_handler.is_some() {
ServiceType::Builtin
} else if api_schema.is_some() {
ServiceType::Api
} else if inference.is_some() {
ServiceType::Inference
} else {
ServiceType::Web
};
trace!(
route_id = %id,
service_type = ?service_type,
match_count = matches.len(),
filter_count = filters.len(),
has_upstream = upstream.is_some(),
"Parsed route"
);
let (request_headers, response_headers) = parse_route_header_policies(child)?;
if let Some(route_children) = child.children() {
for child_node in route_children.nodes() {
let name = child_node.name().value();
if !RECOGNIZED_ROUTE_CHILDREN.contains(&name) {
warn!(
route_id = %id,
directive = %name,
"Unrecognized directive in route block (will be ignored). \
Agents must be configured in a top-level \"agents\" block \
and referenced via filters."
);
}
}
}
let policies = RoutePolicies {
request_headers,
response_headers,
cache: cache_config,
..RoutePolicies::default()
};
routes.push(RouteConfig {
id,
priority,
matches,
upstream,
service_type,
policies,
filters,
builtin_handler,
waf_enabled: get_bool_entry(child, "waf-enabled").unwrap_or(false),
circuit_breaker: None,
retry_policy: None,
static_files,
api_schema,
inference,
error_pages: None,
websocket: get_bool_entry(child, "websocket").unwrap_or(false),
websocket_inspection: get_bool_entry(child, "websocket-inspection")
.unwrap_or(false),
shadow,
fallback: parse_fallback_config_opt(child)?,
});
}
}
}
trace!(route_count = routes.len(), "Finished parsing routes");
Ok(routes)
}
fn parse_match_conditions(node: &kdl::KdlNode) -> Result<Vec<MatchCondition>> {
let mut matches = Vec::new();
if let Some(route_children) = node.children() {
if let Some(matches_node) = route_children.get("matches") {
if let Some(match_children) = matches_node.children() {
for match_node in match_children.nodes() {
match match_node.name().value() {
"path-prefix" => {
if let Some(prefix) = get_first_arg_string(match_node) {
matches.push(MatchCondition::PathPrefix(prefix));
}
}
"path" => {
if let Some(path) = get_first_arg_string(match_node) {
matches.push(MatchCondition::Path(path));
}
}
"path-regex" => {
if let Some(regex) = get_first_arg_string(match_node) {
matches.push(MatchCondition::PathRegex(regex));
}
}
"host" => {
if let Some(host) = get_first_arg_string(match_node) {
matches.push(MatchCondition::Host(host));
}
}
"header" => {
let entries: Vec<_> = match_node.entries().iter().collect();
if let Some(name) = entries.first().and_then(|e| e.value().as_string())
{
let value = entries
.get(1)
.and_then(|e| e.value().as_string())
.map(|s| s.to_string());
matches.push(MatchCondition::Header {
name: name.to_string(),
value,
});
}
}
"method" => {
if let Some(method) = get_first_arg_string(match_node) {
matches.push(MatchCondition::Method(vec![method]));
}
}
"query-param" => {
let entries: Vec<_> = match_node.entries().iter().collect();
if let Some(name) = entries.first().and_then(|e| e.value().as_string())
{
let value = entries
.get(1)
.and_then(|e| e.value().as_string())
.map(|s| s.to_string());
matches.push(MatchCondition::QueryParam {
name: name.to_string(),
value,
});
}
}
_ => {}
}
}
}
}
}
Ok(matches)
}
fn parse_priority(node: &kdl::KdlNode) -> zentinel_common::types::Priority {
use zentinel_common::types::Priority;
if let Some(n) = get_int_entry(node, "priority") {
return Priority(n as i32);
}
match get_string_entry(node, "priority")
.as_deref()
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("critical") => Priority::CRITICAL,
Some("high") => Priority::HIGH,
Some("low") => Priority::LOW,
Some("normal") => Priority::NORMAL,
_ => Priority::NORMAL,
}
}
fn parse_upstream_ref(node: &kdl::KdlNode) -> Option<String> {
if let Some(route_children) = node.children() {
if let Some(upstream_node) = route_children.get("upstream") {
let entry = upstream_node.entries().first();
if let Some(s) = entry.and_then(|e| e.value().as_string()) {
return Some(s.to_string());
}
}
}
None
}
fn parse_static_file_config_opt(node: &kdl::KdlNode) -> Result<Option<StaticFileConfig>> {
if let Some(route_children) = node.children() {
if let Some(static_node) = route_children.get("static-files") {
return Ok(Some(parse_static_file_config(static_node)?));
}
}
Ok(None)
}
fn parse_route_filter_refs(node: &kdl::KdlNode) -> Result<Vec<String>> {
let mut filter_ids = Vec::new();
if let Some(route_children) = node.children() {
if let Some(filters_node) = route_children.get("filters") {
for entry in filters_node.entries() {
if let Some(id) = entry.value().as_string() {
filter_ids.push(id.to_string());
}
}
}
}
Ok(filter_ids)
}
fn parse_route_header_policies(
node: &kdl::KdlNode,
) -> Result<(HeaderModifications, HeaderModifications)> {
let mut request_headers = HeaderModifications::default();
let mut response_headers = HeaderModifications::default();
if let Some(route_children) = node.children() {
if let Some(policies_node) = route_children.get("policies") {
if let Some(policy_children) = policies_node.children() {
if let Some(req_node) = policy_children.get("request-headers") {
request_headers = parse_header_modifications(req_node)?;
}
if let Some(resp_node) = policy_children.get("response-headers") {
response_headers = parse_header_modifications(resp_node)?;
}
}
}
}
Ok((request_headers, response_headers))
}
fn parse_header_modifications(node: &kdl::KdlNode) -> Result<HeaderModifications> {
let mut rename = HashMap::new();
let mut set = HashMap::new();
let mut add = HashMap::new();
let mut remove = Vec::new();
if let Some(children) = node.children() {
if let Some(rename_node) = children.get("rename") {
if let Some(rename_children) = rename_node.children() {
for entry_node in rename_children.nodes() {
let old_name = entry_node.name().value().to_string();
if let Some(new_name) = get_first_arg_string(entry_node) {
rename.insert(old_name, new_name);
}
}
}
}
if let Some(set_node) = children.get("set") {
if let Some(set_children) = set_node.children() {
for entry_node in set_children.nodes() {
let name = entry_node.name().value().to_string();
if let Some(value) = get_first_arg_string(entry_node) {
set.insert(name, value);
}
}
}
}
if let Some(add_node) = children.get("add") {
if let Some(add_children) = add_node.children() {
for entry_node in add_children.nodes() {
let name = entry_node.name().value().to_string();
if let Some(value) = get_first_arg_string(entry_node) {
add.insert(name, value);
}
}
}
}
if let Some(remove_node) = children.get("remove") {
for entry in remove_node.entries() {
if let Some(name) = entry.value().as_string() {
remove.push(name.to_string());
}
}
}
}
Ok(HeaderModifications {
rename,
set,
add,
remove,
})
}
pub fn parse_static_file_config(node: &kdl::KdlNode) -> Result<StaticFileConfig> {
let root = get_string_entry(node, "root").ok_or_else(|| {
anyhow::anyhow!(
"Static files configuration requires a 'root' directory, e.g., root \"/var/www/html\""
)
})?;
Ok(StaticFileConfig {
root: PathBuf::from(root),
index: get_string_entry(node, "index").unwrap_or_else(|| "index.html".to_string()),
directory_listing: get_bool_entry(node, "directory-listing").unwrap_or(false),
cache_control: get_string_entry(node, "cache-control")
.unwrap_or_else(|| "public, max-age=3600".to_string()),
compress: get_bool_entry(node, "compress").unwrap_or(true),
mime_types: HashMap::new(),
fallback: get_string_entry(node, "fallback"),
})
}
fn parse_cache_config_opt(node: &kdl::KdlNode) -> Result<Option<RouteCacheConfig>> {
if let Some(route_children) = node.children() {
if let Some(cache_node) = route_children.get("cache") {
return Ok(Some(parse_cache_config(cache_node)?));
}
}
Ok(None)
}
fn parse_cache_config(node: &kdl::KdlNode) -> Result<RouteCacheConfig> {
let enabled = get_bool_entry(node, "enabled").unwrap_or(false);
let default_ttl_secs = get_int_entry(node, "default-ttl-secs").unwrap_or(3600) as u64;
let max_size_bytes = get_int_entry(node, "max-size-bytes").unwrap_or(10 * 1024 * 1024) as usize;
let cache_private = get_bool_entry(node, "cache-private").unwrap_or(false);
let stale_while_revalidate_secs =
get_int_entry(node, "stale-while-revalidate-secs").unwrap_or(60) as u64;
let stale_if_error_secs = get_int_entry(node, "stale-if-error-secs").unwrap_or(300) as u64;
let cacheable_methods = if let Some(children) = node.children() {
if let Some(methods_node) = children.get("cacheable-methods") {
methods_node
.entries()
.iter()
.filter_map(|e| e.value().as_string().map(|s| s.to_string()))
.collect()
} else {
vec!["GET".to_string(), "HEAD".to_string()]
}
} else {
vec!["GET".to_string(), "HEAD".to_string()]
};
let cacheable_status_codes = if let Some(children) = node.children() {
if let Some(codes_node) = children.get("cacheable-status-codes") {
codes_node
.entries()
.iter()
.filter_map(|e| e.value().as_integer().map(|v| v as u16))
.collect()
} else {
vec![200, 203, 204, 206, 300, 301, 308, 404, 410]
}
} else {
vec![200, 203, 204, 206, 300, 301, 308, 404, 410]
};
let vary_headers = if let Some(children) = node.children() {
if let Some(vary_node) = children.get("vary-headers") {
vary_node
.entries()
.iter()
.filter_map(|e| e.value().as_string().map(|s| s.to_string()))
.collect()
} else {
Vec::new()
}
} else {
Vec::new()
};
let ignore_query_params = if let Some(children) = node.children() {
if let Some(ignore_node) = children.get("ignore-query-params") {
ignore_node
.entries()
.iter()
.filter_map(|e| e.value().as_string().map(|s| s.to_string()))
.collect()
} else {
Vec::new()
}
} else {
Vec::new()
};
let exclude_extensions = if let Some(children) = node.children() {
if let Some(ext_node) = children.get("exclude-extensions") {
ext_node
.entries()
.iter()
.filter_map(|e| e.value().as_string().map(|s| s.to_string()))
.collect()
} else {
Vec::new()
}
} else {
Vec::new()
};
let exclude_paths = if let Some(children) = node.children() {
if let Some(paths_node) = children.get("exclude-paths") {
paths_node
.entries()
.iter()
.filter_map(|e| e.value().as_string().map(|s| s.to_string()))
.collect()
} else {
Vec::new()
}
} else {
Vec::new()
};
trace!(
enabled = enabled,
default_ttl = default_ttl_secs,
max_size = max_size_bytes,
"Parsed cache configuration"
);
Ok(RouteCacheConfig {
enabled,
default_ttl_secs,
max_size_bytes,
cache_private,
stale_while_revalidate_secs,
stale_if_error_secs,
cacheable_methods,
cacheable_status_codes,
vary_headers,
ignore_query_params,
exclude_extensions,
exclude_paths,
})
}
fn parse_api_schema_config_opt(node: &kdl::KdlNode) -> Result<Option<ApiSchemaConfig>> {
if let Some(route_children) = node.children() {
if let Some(api_schema_node) = route_children.get("api-schema") {
return Ok(Some(parse_api_schema_config(api_schema_node)?));
}
}
Ok(None)
}
fn parse_api_schema_config(node: &kdl::KdlNode) -> Result<ApiSchemaConfig> {
let schema_file = get_string_entry(node, "schema-file").map(PathBuf::from);
let schema_content = get_string_entry(node, "schema-content");
let validate_requests = get_bool_entry(node, "validate-requests").unwrap_or(true);
let validate_responses = get_bool_entry(node, "validate-responses").unwrap_or(false);
let strict_mode = get_bool_entry(node, "strict-mode").unwrap_or(false);
if schema_file.is_some() && schema_content.is_some() {
return Err(anyhow::anyhow!(
"schema-file and schema-content are mutually exclusive. Use one or the other."
));
}
let request_schema = if let Some(children) = node.children() {
if let Some(schema_node) = children.get("request-schema") {
Some(super::kdl_to_json(schema_node)?)
} else {
None
}
} else {
None
};
let response_schema = if let Some(children) = node.children() {
if let Some(schema_node) = children.get("response-schema") {
Some(super::kdl_to_json(schema_node)?)
} else {
None
}
} else {
None
};
trace!(
has_schema_file = schema_file.is_some(),
has_schema_content = schema_content.is_some(),
has_request_schema = request_schema.is_some(),
has_response_schema = response_schema.is_some(),
validate_requests = validate_requests,
validate_responses = validate_responses,
strict_mode = strict_mode,
"Parsed API schema configuration"
);
Ok(ApiSchemaConfig {
schema_file,
schema_content,
request_schema,
response_schema,
validate_requests,
validate_responses,
strict_mode,
})
}
fn parse_shadow_config_opt(node: &kdl::KdlNode) -> Result<Option<ShadowConfig>> {
if let Some(route_children) = node.children() {
if let Some(shadow_node) = route_children.get("shadow") {
return Ok(Some(parse_shadow_config(shadow_node)?));
}
}
Ok(None)
}
fn parse_shadow_config(node: &kdl::KdlNode) -> Result<ShadowConfig> {
let upstream = get_string_entry(node, "upstream").ok_or_else(|| {
anyhow::anyhow!(
"Shadow configuration requires an 'upstream' field, e.g., upstream \"canary\""
)
})?;
let percentage = if let Some(pct_str) = get_string_entry(node, "percentage") {
pct_str.parse::<f64>().unwrap_or(100.0)
} else {
get_int_entry(node, "percentage")
.map(|v| v as f64)
.unwrap_or(100.0)
};
let timeout_ms = get_int_entry(node, "timeout-ms").unwrap_or(5000) as u64;
let buffer_body = get_bool_entry(node, "buffer-body").unwrap_or(false);
let max_body_bytes = get_int_entry(node, "max-body-bytes").unwrap_or(1048576) as usize;
let sample_header = if let Some(children) = node.children() {
if let Some(header_node) = children.get("sample-header") {
let entries: Vec<_> = header_node.entries().iter().collect();
if entries.len() >= 2 {
let name = entries[0]
.value()
.as_string()
.ok_or_else(|| anyhow::anyhow!("sample-header name must be a string"))?;
let value = entries[1]
.value()
.as_string()
.ok_or_else(|| anyhow::anyhow!("sample-header value must be a string"))?;
Some((name.to_string(), value.to_string()))
} else {
None
}
} else {
None
}
} else {
None
};
trace!(
upstream = %upstream,
percentage = percentage,
timeout_ms = timeout_ms,
buffer_body = buffer_body,
max_body_bytes = max_body_bytes,
has_sample_header = sample_header.is_some(),
"Parsed shadow configuration"
);
Ok(ShadowConfig {
upstream,
percentage,
sample_header,
timeout_ms,
buffer_body,
max_body_bytes,
})
}
fn parse_fallback_config_opt(node: &kdl::KdlNode) -> Result<Option<FallbackConfig>> {
if let Some(route_children) = node.children() {
if let Some(fallback_node) = route_children.get("fallback") {
return Ok(Some(parse_fallback_config(fallback_node)?));
}
}
Ok(None)
}
fn parse_fallback_config(node: &kdl::KdlNode) -> Result<FallbackConfig> {
let max_attempts = get_int_entry(node, "max-attempts").unwrap_or(3) as u32;
let triggers = if let Some(children) = node.children() {
if let Some(triggers_node) = children.get("triggers") {
parse_fallback_triggers(triggers_node)?
} else {
FallbackTriggers::default()
}
} else {
FallbackTriggers::default()
};
let upstreams = parse_fallback_upstreams(node)?;
trace!(
max_attempts = max_attempts,
upstream_count = upstreams.len(),
on_health_failure = triggers.on_health_failure,
on_connection_error = triggers.on_connection_error,
"Parsed fallback configuration"
);
Ok(FallbackConfig {
upstreams,
triggers,
max_attempts,
})
}
fn parse_fallback_triggers(node: &kdl::KdlNode) -> Result<FallbackTriggers> {
let on_health_failure = get_bool_entry(node, "on-health-failure").unwrap_or(true);
let on_budget_exhausted = get_bool_entry(node, "on-budget-exhausted").unwrap_or(false);
let on_latency_threshold_ms = get_int_entry(node, "on-latency-threshold-ms").map(|v| v as u64);
let on_connection_error = get_bool_entry(node, "on-connection-error").unwrap_or(true);
let on_error_codes = if let Some(children) = node.children() {
if let Some(codes_node) = children.get("on-error-codes") {
codes_node
.entries()
.iter()
.filter_map(|e| e.value().as_integer().map(|v| v as u16))
.collect()
} else {
Vec::new()
}
} else {
node.children()
.and_then(|c| c.get("on-error-codes"))
.map(|n| {
n.entries()
.iter()
.filter_map(|e| e.value().as_integer().map(|v| v as u16))
.collect()
})
.unwrap_or_default()
};
Ok(FallbackTriggers {
on_health_failure,
on_budget_exhausted,
on_latency_threshold_ms,
on_error_codes,
on_connection_error,
})
}
fn parse_fallback_upstreams(node: &kdl::KdlNode) -> Result<Vec<FallbackUpstream>> {
let mut upstreams = Vec::new();
if let Some(children) = node.children() {
for child in children.nodes() {
if child.name().value() == "fallback-upstream" {
let upstream_id = get_first_arg_string(child).ok_or_else(|| {
anyhow::anyhow!(
"fallback-upstream requires an upstream ID, e.g., fallback-upstream \"anthropic\" {{ ... }}"
)
})?;
let provider = parse_inference_provider(child);
let skip_if_unhealthy = get_bool_entry(child, "skip-if-unhealthy").unwrap_or(false);
let model_mapping = parse_model_mapping(child)?;
trace!(
upstream = %upstream_id,
provider = ?provider,
skip_if_unhealthy = skip_if_unhealthy,
model_mapping_count = model_mapping.len(),
"Parsed fallback upstream"
);
upstreams.push(FallbackUpstream {
upstream: upstream_id,
provider,
model_mapping,
skip_if_unhealthy,
});
}
}
}
Ok(upstreams)
}
fn parse_model_mapping(node: &kdl::KdlNode) -> Result<HashMap<String, String>> {
let mut mapping = HashMap::new();
if let Some(children) = node.children() {
if let Some(mapping_node) = children.get("model-mapping") {
if let Some(mapping_children) = mapping_node.children() {
for entry_node in mapping_children.nodes() {
let entries: Vec<_> = entry_node
.entries()
.iter()
.filter_map(|e| e.value().as_string().map(|s| s.to_string()))
.collect();
let source = entry_node.name().value().to_string();
if let Some(target) = entries.first() {
mapping.insert(source, target.clone());
}
}
}
let entries: Vec<_> = mapping_node
.entries()
.iter()
.filter_map(|e| e.value().as_string().map(|s| s.to_string()))
.collect();
for chunk in entries.chunks(2) {
if chunk.len() == 2 {
mapping.insert(chunk[0].clone(), chunk[1].clone());
}
}
}
}
Ok(mapping)
}
fn parse_inference_provider(node: &kdl::KdlNode) -> InferenceProvider {
match get_string_entry(node, "provider").as_deref() {
Some("openai") => InferenceProvider::OpenAi,
Some("anthropic") => InferenceProvider::Anthropic,
_ => InferenceProvider::Generic,
}
}
fn parse_model_routing_config_opt(node: &kdl::KdlNode) -> Result<Option<ModelRoutingConfig>> {
if let Some(children) = node.children() {
if let Some(routing_node) = children.get("model-routing") {
return Ok(Some(parse_model_routing_config(routing_node)?));
}
}
Ok(None)
}
fn parse_model_routing_config(node: &kdl::KdlNode) -> Result<ModelRoutingConfig> {
let mut mappings = Vec::new();
let mut default_upstream = None;
if let Some(def) = get_string_entry(node, "default-upstream") {
default_upstream = Some(def);
}
if let Some(children) = node.children() {
if let Some(def_node) = children.get("default-upstream") {
if let Some(first_entry) = def_node.entries().first() {
if let Some(val) = first_entry.value().as_string() {
default_upstream = Some(val.to_string());
}
}
}
for model_node in children.nodes() {
if model_node.name().value() == "model" {
if let Some(mapping) = parse_model_upstream_mapping(model_node)? {
mappings.push(mapping);
}
}
}
}
tracing::trace!(
mappings_count = mappings.len(),
default_upstream = ?default_upstream,
"Parsed model routing configuration"
);
Ok(ModelRoutingConfig {
mappings,
default_upstream,
})
}
fn parse_model_upstream_mapping(node: &kdl::KdlNode) -> Result<Option<ModelUpstreamMapping>> {
let model_pattern = node
.entries()
.iter()
.find(|e| e.name().is_none())
.and_then(|e| e.value().as_string())
.map(|s| s.to_string());
let model_pattern = match model_pattern {
Some(p) => p,
None => return Ok(None), };
let upstream = node
.entries()
.iter()
.find(|e| e.name().map(|n| n.value()) == Some("upstream"))
.and_then(|e| e.value().as_string())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("Model mapping requires 'upstream' attribute"))?;
let provider_str = node
.entries()
.iter()
.find(|e| e.name().map(|n| n.value()) == Some("provider"))
.and_then(|e| e.value().as_string());
let provider = match provider_str {
Some("openai") => Some(InferenceProvider::OpenAi),
Some("anthropic") => Some(InferenceProvider::Anthropic),
Some("generic") => Some(InferenceProvider::Generic),
Some(_) | None => None,
};
tracing::trace!(
model_pattern = %model_pattern,
upstream = %upstream,
provider = ?provider,
"Parsed model upstream mapping"
);
Ok(Some(ModelUpstreamMapping {
model_pattern,
upstream,
provider,
}))
}
fn parse_inference_config_opt(node: &kdl::KdlNode) -> Result<Option<InferenceConfig>> {
if let Some(route_children) = node.children() {
if let Some(inference_node) = route_children.get("inference") {
return Ok(Some(parse_inference_config(inference_node)?));
}
}
Ok(None)
}
fn parse_inference_config(node: &kdl::KdlNode) -> Result<InferenceConfig> {
let provider = match get_string_entry(node, "provider").as_deref() {
Some("openai") | Some("open-ai") | Some("open_ai") => InferenceProvider::OpenAi,
Some("anthropic") => InferenceProvider::Anthropic,
Some("generic") | None => InferenceProvider::Generic,
Some(other) => {
return Err(anyhow::anyhow!(
"Unknown inference provider '{}'. Valid providers: openai, anthropic, generic",
other
));
}
};
let model_header = get_string_entry(node, "model-header");
let rate_limit = if let Some(children) = node.children() {
if let Some(rl_node) = children.get("rate-limit") {
Some(parse_token_rate_limit(rl_node)?)
} else {
None
}
} else {
None
};
let routing = if let Some(children) = node.children() {
if let Some(routing_node) = children.get("routing") {
Some(parse_inference_routing(routing_node)?)
} else {
None
}
} else {
None
};
let budget = if let Some(children) = node.children() {
if let Some(budget_node) = children.get("budget") {
Some(parse_token_budget(budget_node)?)
} else {
None
}
} else {
None
};
let cost_attribution = if let Some(children) = node.children() {
if let Some(cost_node) = children.get("cost-attribution") {
Some(parse_cost_attribution(cost_node)?)
} else {
None
}
} else {
None
};
trace!(
provider = ?provider,
has_rate_limit = rate_limit.is_some(),
has_routing = routing.is_some(),
has_budget = budget.is_some(),
has_cost = cost_attribution.is_some(),
"Parsed inference configuration"
);
let model_routing = parse_model_routing_config_opt(node)?;
let guardrails = parse_guardrails_config_opt(node)?;
Ok(InferenceConfig {
provider,
model_header,
rate_limit,
budget,
cost_attribution,
routing,
model_routing,
guardrails,
})
}
fn parse_token_rate_limit(node: &kdl::KdlNode) -> Result<TokenRateLimit> {
let tokens_per_minute = get_int_entry(node, "tokens-per-minute")
.ok_or_else(|| anyhow::anyhow!("Token rate limit requires 'tokens-per-minute'"))?
as u64;
let requests_per_minute = get_int_entry(node, "requests-per-minute").map(|v| v as u64);
let burst_tokens = get_int_entry(node, "burst-tokens").unwrap_or(10000) as u64;
let estimation_method = match get_string_entry(node, "estimation-method").as_deref() {
Some("chars") | Some("characters") | None => TokenEstimation::Chars,
Some("words") => TokenEstimation::Words,
Some("tiktoken") => TokenEstimation::Tiktoken,
Some(other) => {
return Err(anyhow::anyhow!(
"Unknown token estimation method '{}'. Valid methods: chars, words, tiktoken",
other
));
}
};
Ok(TokenRateLimit {
tokens_per_minute,
requests_per_minute,
burst_tokens,
estimation_method,
})
}
fn parse_inference_routing(node: &kdl::KdlNode) -> Result<InferenceRouting> {
let strategy = match get_string_entry(node, "strategy").as_deref() {
Some("least-tokens-queued") | Some("least_tokens_queued") | None => {
InferenceRoutingStrategy::LeastTokensQueued
}
Some("round-robin") | Some("round_robin") => InferenceRoutingStrategy::RoundRobin,
Some("least-latency") | Some("least_latency") => InferenceRoutingStrategy::LeastLatency,
Some(other) => {
return Err(anyhow::anyhow!(
"Unknown inference routing strategy '{}'. Valid strategies: least-tokens-queued, round-robin, least-latency",
other
));
}
};
let queue_depth_header = get_string_entry(node, "queue-depth-header");
Ok(InferenceRouting {
strategy,
queue_depth_header,
})
}
fn parse_token_budget(node: &kdl::KdlNode) -> Result<TokenBudgetConfig> {
let period = match get_string_entry(node, "period").as_deref() {
Some("hourly") => BudgetPeriod::Hourly,
Some("daily") | None => BudgetPeriod::Daily,
Some("monthly") => BudgetPeriod::Monthly,
Some(other) => {
if let Ok(seconds) = other.parse::<u64>() {
BudgetPeriod::Custom { seconds }
} else {
return Err(anyhow::anyhow!(
"Unknown budget period '{}'. Valid periods: hourly, daily, monthly, or a number of seconds",
other
));
}
}
};
let limit = get_int_entry(node, "limit")
.ok_or_else(|| anyhow::anyhow!("Token budget requires 'limit'"))? as u64;
let alert_thresholds = if let Some(children) = node.children() {
if let Some(threshold_node) = children.get("alert-thresholds") {
threshold_node
.entries()
.iter()
.filter_map(|e| {
e.value()
.as_float()
.or_else(|| e.value().as_integer().map(|i| i as f64))
})
.collect()
} else {
vec![0.80, 0.90, 0.95]
}
} else {
vec![0.80, 0.90, 0.95]
};
let enforce = get_bool_entry(node, "enforce").unwrap_or(true);
let rollover = get_bool_entry(node, "rollover").unwrap_or(false);
let burst_allowance = get_float_entry(node, "burst-allowance");
trace!(
period = ?period,
limit = limit,
alert_thresholds = ?alert_thresholds,
enforce = enforce,
rollover = rollover,
burst_allowance = ?burst_allowance,
"Parsed token budget configuration"
);
Ok(TokenBudgetConfig {
period,
limit,
alert_thresholds,
enforce,
rollover,
burst_allowance,
})
}
fn parse_cost_attribution(node: &kdl::KdlNode) -> Result<CostAttributionConfig> {
let enabled = get_bool_entry(node, "enabled").unwrap_or(true);
let default_input_cost = get_float_entry(node, "default-input-cost").unwrap_or(1.0);
let default_output_cost = get_float_entry(node, "default-output-cost").unwrap_or(2.0);
let currency = get_string_entry(node, "currency").unwrap_or_else(|| "USD".to_string());
let pricing = if let Some(children) = node.children() {
if let Some(pricing_node) = children.get("pricing") {
parse_model_pricing_list(pricing_node)?
} else {
Vec::new()
}
} else {
Vec::new()
};
trace!(
enabled = enabled,
default_input_cost = default_input_cost,
default_output_cost = default_output_cost,
currency = %currency,
pricing_rules = pricing.len(),
"Parsed cost attribution configuration"
);
Ok(CostAttributionConfig {
enabled,
pricing,
default_input_cost,
default_output_cost,
currency,
})
}
fn parse_model_pricing_list(node: &kdl::KdlNode) -> Result<Vec<ModelPricing>> {
let mut pricing = Vec::new();
if let Some(children) = node.children() {
for child in children.nodes() {
if child.name().value() == "model" {
let pattern = get_first_arg_string(child)
.ok_or_else(|| anyhow::anyhow!("Model pricing requires a pattern argument"))?;
let input_cost =
get_float_entry(child, "input-cost-per-million").ok_or_else(|| {
anyhow::anyhow!("Model pricing requires 'input-cost-per-million'")
})?;
let output_cost =
get_float_entry(child, "output-cost-per-million").ok_or_else(|| {
anyhow::anyhow!("Model pricing requires 'output-cost-per-million'")
})?;
let currency = get_string_entry(child, "currency");
pricing.push(ModelPricing {
model_pattern: pattern,
input_cost_per_million: input_cost,
output_cost_per_million: output_cost,
currency,
});
}
}
}
Ok(pricing)
}
fn parse_guardrails_config_opt(node: &kdl::KdlNode) -> Result<Option<GuardrailsConfig>> {
if let Some(children) = node.children() {
if let Some(guardrails_node) = children.get("guardrails") {
return Ok(Some(parse_guardrails_config(guardrails_node)?));
}
}
Ok(None)
}
fn parse_guardrails_config(node: &kdl::KdlNode) -> Result<GuardrailsConfig> {
let prompt_injection = if let Some(children) = node.children() {
if let Some(pi_node) = children.get("prompt-injection") {
Some(parse_prompt_injection_config(pi_node)?)
} else {
None
}
} else {
None
};
let pii_detection = if let Some(children) = node.children() {
if let Some(pii_node) = children.get("pii-detection") {
Some(parse_pii_detection_config(pii_node)?)
} else {
None
}
} else {
None
};
trace!(
has_prompt_injection = prompt_injection.is_some(),
has_pii_detection = pii_detection.is_some(),
"Parsed guardrails configuration"
);
Ok(GuardrailsConfig {
prompt_injection,
pii_detection,
})
}
fn parse_prompt_injection_config(node: &kdl::KdlNode) -> Result<PromptInjectionConfig> {
let enabled = get_bool_entry(node, "enabled").unwrap_or(false);
let agent = get_string_entry(node, "agent")
.ok_or_else(|| anyhow::anyhow!("Prompt injection config requires 'agent' field"))?;
let action = match get_string_entry(node, "action").as_deref() {
Some("block") => GuardrailAction::Block,
Some("log") | None => GuardrailAction::Log,
Some("warn") => GuardrailAction::Warn,
Some(other) => {
return Err(anyhow::anyhow!(
"Unknown guardrail action '{}'. Valid actions: block, log, warn",
other
));
}
};
let block_status = get_int_entry(node, "block-status").unwrap_or(400) as u16;
let block_message = get_string_entry(node, "block-message");
let timeout_ms = get_int_entry(node, "timeout-ms").unwrap_or(500) as u64;
let failure_mode = match get_string_entry(node, "failure-mode").as_deref() {
Some("open") | None => GuardrailFailureMode::Open,
Some("closed") => GuardrailFailureMode::Closed,
Some(other) => {
return Err(anyhow::anyhow!(
"Unknown failure mode '{}'. Valid modes: open, closed",
other
));
}
};
trace!(
enabled = enabled,
agent = %agent,
action = ?action,
block_status = block_status,
timeout_ms = timeout_ms,
failure_mode = ?failure_mode,
"Parsed prompt injection configuration"
);
Ok(PromptInjectionConfig {
enabled,
agent,
action,
block_status,
block_message,
timeout_ms,
failure_mode,
})
}
fn parse_pii_detection_config(node: &kdl::KdlNode) -> Result<PiiDetectionConfig> {
let enabled = get_bool_entry(node, "enabled").unwrap_or(false);
let agent = get_string_entry(node, "agent")
.ok_or_else(|| anyhow::anyhow!("PII detection config requires 'agent' field"))?;
let action = match get_string_entry(node, "action").as_deref() {
Some("log") | None => PiiAction::Log,
Some("redact") => PiiAction::Redact,
Some("block") => PiiAction::Block,
Some(other) => {
return Err(anyhow::anyhow!(
"Unknown PII action '{}'. Valid actions: log, redact, block",
other
));
}
};
let categories = if let Some(children) = node.children() {
if let Some(cat_node) = children.get("categories") {
cat_node
.entries()
.iter()
.filter_map(|e| e.value().as_string().map(|s| s.to_string()))
.collect()
} else {
Vec::new()
}
} else {
Vec::new()
};
let timeout_ms = get_int_entry(node, "timeout-ms").unwrap_or(1000) as u64;
let failure_mode = match get_string_entry(node, "failure-mode").as_deref() {
Some("open") | None => GuardrailFailureMode::Open,
Some("closed") => GuardrailFailureMode::Closed,
Some(other) => {
return Err(anyhow::anyhow!(
"Unknown failure mode '{}'. Valid modes: open, closed",
other
));
}
};
trace!(
enabled = enabled,
agent = %agent,
action = ?action,
categories = ?categories,
timeout_ms = timeout_ms,
failure_mode = ?failure_mode,
"Parsed PII detection configuration"
);
Ok(PiiDetectionConfig {
enabled,
agent,
action,
categories,
timeout_ms,
failure_mode,
})
}
#[cfg(test)]
mod tests {
use super::*;
use zentinel_common::types::Priority;
fn parse_priority_from(kdl: &str) -> Priority {
let doc: ::kdl::KdlDocument = kdl.parse().expect("KDL parses");
let route_node = doc.get("route").expect("route node present");
parse_priority(route_node)
}
#[test]
fn priority_accepts_integer() {
assert_eq!(
parse_priority_from(r#"route "r" { priority 100 }"#),
Priority(100)
);
assert_eq!(
parse_priority_from(r#"route "r" { priority 1000 }"#),
Priority::CRITICAL
);
assert_eq!(
parse_priority_from(r#"route "r" { priority 1 }"#),
Priority(1)
);
}
#[test]
fn priority_accepts_large_and_negative_integers() {
assert_eq!(
parse_priority_from(r#"route "r" { priority 999999 }"#),
Priority(999_999)
);
assert_eq!(
parse_priority_from(r#"route "r" { priority -50 }"#),
Priority(-50)
);
}
#[test]
fn priority_accepts_all_string_aliases() {
assert_eq!(
parse_priority_from(r#"route "r" { priority "critical" }"#),
Priority::CRITICAL
);
assert_eq!(
parse_priority_from(r#"route "r" { priority "high" }"#),
Priority::HIGH
);
assert_eq!(
parse_priority_from(r#"route "r" { priority "normal" }"#),
Priority::NORMAL
);
assert_eq!(
parse_priority_from(r#"route "r" { priority "low" }"#),
Priority::LOW
);
}
#[test]
fn priority_string_aliases_are_case_insensitive() {
assert_eq!(
parse_priority_from(r#"route "r" { priority "HIGH" }"#),
Priority::HIGH
);
assert_eq!(
parse_priority_from(r#"route "r" { priority "Critical" }"#),
Priority::CRITICAL
);
}
#[test]
fn priority_unknown_string_falls_back_to_normal() {
assert_eq!(
parse_priority_from(r#"route "r" { priority "medium" }"#),
Priority::NORMAL
);
}
#[test]
fn priority_missing_is_normal() {
assert_eq!(
parse_priority_from(r#"route "r" { upstream "backend" }"#),
Priority::NORMAL
);
}
#[test]
fn numeric_priorities_sort_before_named_aliases() {
assert!(Priority(500) > Priority::HIGH);
assert!(Priority::HIGH > Priority(75));
assert!(Priority(75) > Priority::NORMAL);
assert!(Priority::NORMAL > Priority(25));
assert!(Priority(25) > Priority::LOW);
}
}