mod context_rooted;
pub use context_rooted::{
pool_resource_ids, project_to_proxy, resolve_all_contexts, resolve_embedder, resolve_leg,
validate_context_rooted, ContextCacheConfig, ContextEmbedderConfig, ContextLegConfig,
EmbedderResourceConfig, NamedContextConfig, ResolvedContext, ResolvedEmbedder,
ResolvedUpstreamLeg, ServerConfig, UpstreamResourceConfig,
};
use crate::error::{ConproxyError, Result};
use crate::proxy::cascade::CascadeConfig;
use crate::proxy::circuit::CircuitBreakerConfig;
use crate::proxy::connection_pool::ConnectionPoolConfig;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
fn default_true() -> bool {
true
}
#[derive(Debug, Clone)]
pub struct Config {
pub config: ConfigFile,
pub local_root: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(default, skip_serializing)]
llm: Option<toml::Value>,
#[serde(default)]
pub packages: HashMap<String, PackageEntry>,
#[serde(default)]
pub registries: HashMap<String, String>,
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub embedding: EmbeddingConfig,
#[serde(default)]
pub web: WebConfig,
#[serde(default)]
pub proxy: ProxyConfig,
#[serde(default)]
pub context: ContextConfig,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub upstreams: HashMap<String, UpstreamResourceConfig>,
#[serde(default)]
pub embedders: HashMap<String, EmbedderResourceConfig>,
#[serde(default)]
pub contexts: HashMap<String, NamedContextConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<AgentConfig>,
}
impl ConfigFile {
pub fn merge_with(&self, other: &ConfigFile) -> ConfigFile {
ConfigFile {
llm: None,
packages: if other.packages.is_empty() {
self.packages.clone()
} else {
other.packages.clone()
},
registries: {
let mut merged = self.registries.clone();
merged.extend(other.registries.clone());
merged
},
search: other.search.merge_with(&self.search),
embedding: other.embedding.merge_with(&self.embedding),
web: other.web.merge_with(&self.web),
proxy: other.proxy.merge_with(&self.proxy),
context: other.context.merge_with(&self.context),
server: other.server.merge_with(&self.server),
upstreams: {
let mut m = self.upstreams.clone();
m.extend(other.upstreams.clone());
m
},
embedders: {
let mut m = self.embedders.clone();
m.extend(other.embedders.clone());
m
},
contexts: if other.contexts.is_empty() {
self.contexts.clone()
} else {
other.contexts.clone()
},
agents: if other.agents.is_empty() {
self.agents.clone()
} else {
other.agents.clone()
},
}
}
pub fn default_global() -> Self {
ConfigFile {
llm: None,
packages: HashMap::new(),
registries: HashMap::new(),
search: SearchConfig::default(),
embedding: EmbeddingConfig::default(),
web: WebConfig::default(),
proxy: ProxyConfig::default(),
context: ContextConfig::default(),
server: ServerConfig::default(),
upstreams: HashMap::new(),
embedders: HashMap::new(),
contexts: HashMap::new(),
agents: Vec::new(),
}
}
pub fn default_local() -> Self {
ConfigFile {
llm: None,
packages: HashMap::new(),
registries: HashMap::new(),
search: SearchConfig::default(),
embedding: EmbeddingConfig::default(),
web: WebConfig::default(),
proxy: ProxyConfig::default(),
context: ContextConfig::default(),
server: ServerConfig::default(),
upstreams: HashMap::new(),
embedders: HashMap::new(),
contexts: HashMap::new(),
agents: Vec::new(),
}
}
pub fn validate(&self) -> std::result::Result<(), String> {
if self.llm.is_some() {
return Err(
"[llm] / [[llm.providers]] removed (plan 01). Use an external gateway (e.g. LiteLLM) for provider proxy/routing. conproxy owns search cache and distill only.".into(),
);
}
let proxy = &self.proxy;
for upstream in &proxy.upstreams {
upstream.validate()?;
if !upstream.url.starts_with("http://")
&& !upstream.url.starts_with("https://")
&& !upstream.url.starts_with("postgres://")
&& !upstream.url.starts_with("postgresql://")
{
return Err(format!(
"upstream '{}': URL must start with http(s):// or postgres(ql)://, got '{}'",
upstream.id, upstream.url
));
}
if let Some(t) = upstream.timeout_secs {
if t == 0 || t > 300 {
return Err(format!(
"upstream '{}': timeout_secs must be 1..=300, got {}",
upstream.id, t
));
}
}
}
if let Some(ref url_str) = proxy.upstream_url {
if !url_str.starts_with("http://") && !url_str.starts_with("https://") {
return Err(format!(
"proxy.upstream_url: must start with http(s)://, got '{}'",
url_str
));
}
}
if let Some(t) = proxy.upstream_timeout_secs {
if t == 0 || t > 300 {
return Err(format!(
"proxy.upstream_timeout_secs must be 1..=300, got {}",
t
));
}
}
if let Some(max) = proxy.max_entries {
if max == 0 {
return Err("proxy.max_entries must be > 0".to_string());
}
}
if proxy.pool.max_connections == 0 || proxy.pool.max_connections > 10_000 {
return Err(format!(
"proxy.pool.max_connections must be 1..=10000, got {}",
proxy.pool.max_connections
));
}
if proxy.socket_tuning.listen_backlog > 65535 {
return Err(format!(
"proxy.socket_tuning.listen_backlog must be <= 65535, got {}",
proxy.socket_tuning.listen_backlog
));
}
if let Some(secs) = proxy.fresh_duration_secs {
if secs == 0 {
return Err("proxy.fresh_duration_secs must be > 0".to_string());
}
}
if let Some(secs) = proxy.stale_duration_secs {
if secs == 0 {
return Err("proxy.stale_duration_secs must be > 0".to_string());
}
}
if let Some(secs) = proxy.refresh_interval_secs {
if secs == 0 {
return Err("proxy.refresh_interval_secs must be > 0".to_string());
}
}
proxy.distill.validate()?;
proxy.federated.validate()?;
proxy.scope.validate()?;
proxy.rate_limit.validate()?;
proxy.retry.validate()?;
proxy.cache.validate()?;
proxy.security.validate()?;
proxy.circuit_breaker.validate()?;
let agents_for_ctx: Vec<AgentConfig> = if self.agents.is_empty() {
proxy.agents.clone()
} else {
self.agents.clone()
};
validate_context_rooted(
&self.upstreams,
&self.embedders,
&self.contexts,
&agents_for_ctx,
)?;
Ok(())
}
pub fn is_context_rooted(&self) -> bool {
!self.contexts.is_empty()
}
#[must_use]
pub fn effective_scope_seeds(&self) -> Vec<String> {
if let Some(default_ctx) = self.contexts.values().find(|c| c.default == Some(true)) {
default_ctx.scope.phrase_texts()
} else {
self.proxy.scope.phrase_texts()
}
}
pub fn resolve_contexts(&self) -> std::result::Result<Vec<ResolvedContext>, String> {
resolve_all_contexts(&self.upstreams, &self.embedders, &self.contexts)
}
pub fn effective_proxy(&self) -> std::result::Result<ProxyConfig, String> {
let mut base = self.proxy.clone();
base.normalize_upstreams();
if let Some(ref l) = self.server.listen {
base.listen = Some(l.clone());
}
if let Some(ref h) = self.server.http_listen {
base.http_listen = Some(h.clone());
}
let resolved = self.resolve_contexts()?;
let mut projected = project_to_proxy(&base, &resolved)?;
if !self.agents.is_empty() {
projected.agents = self.agents.clone();
}
Ok(projected)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageEntry {
pub git: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SearchConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub use_hybrid: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_limit: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_index_on_install: Option<bool>,
#[serde(default)]
pub federated: FederatedConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FederatedConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_local_results: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_local_confidence: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback_on_empty: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback_on_low_confidence: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merge_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_merged_results: Option<usize>,
}
impl FederatedConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
enabled: self.enabled.or(base.enabled),
min_local_results: self.min_local_results.or(base.min_local_results),
min_local_confidence: self.min_local_confidence.or(base.min_local_confidence),
fallback_on_empty: self.fallback_on_empty.or(base.fallback_on_empty),
fallback_on_low_confidence: self
.fallback_on_low_confidence
.or(base.fallback_on_low_confidence),
merge_mode: self.merge_mode.clone().or_else(|| base.merge_mode.clone()),
max_merged_results: self.max_merged_results.or(base.max_merged_results),
}
}
pub fn enabled(&self) -> bool {
self.enabled.unwrap_or(false)
}
pub fn min_local_results(&self) -> usize {
self.min_local_results.unwrap_or(3)
}
pub fn min_local_confidence(&self) -> f32 {
self.min_local_confidence.unwrap_or(0.7)
}
pub fn merge_mode(&self) -> &str {
self.merge_mode.as_deref().unwrap_or("local_only_fallback")
}
pub fn max_merged_results(&self) -> usize {
self.max_merged_results.unwrap_or(10)
}
pub fn validate(&self) -> std::result::Result<(), String> {
if let Some(ref val) = self.min_local_confidence {
if !(0.0..=1.0).contains(val) {
return Err("federated.min_local_confidence must be between 0.0 and 1.0".into());
}
}
if let Some(ref mode) = self.merge_mode {
let lower = mode.to_lowercase();
let valid = [
"local_only_fallback",
"local_priority",
"remote_priority",
"interleave",
];
if !valid.contains(&lower.as_str()) {
return Err(format!(
"federated.merge_mode '{}' invalid, expected one of: {}",
mode,
valid.join(", ")
));
}
}
if let Some(ref val) = self.max_merged_results {
if *val == 0 {
return Err("federated.max_merged_results must be > 0".into());
}
}
if let Some(ref val) = self.min_local_results {
if *val == 0 {
return Err("federated.min_local_results must be > 0".into());
}
}
Ok(())
}
}
impl SearchConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
use_hybrid: self.use_hybrid.or(base.use_hybrid),
default_limit: self.default_limit.or(base.default_limit),
auto_index_on_install: self.auto_index_on_install.or(base.auto_index_on_install),
federated: self.federated.merge_with(&base.federated),
}
}
pub fn use_hybrid(&self) -> bool {
self.use_hybrid.unwrap_or(false)
}
pub fn default_limit(&self) -> usize {
self.default_limit.unwrap_or(10)
}
pub fn auto_index_on_install(&self) -> bool {
self.auto_index_on_install.unwrap_or(false)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EmbeddingConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokenizer_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub batch_size: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
}
impl EmbeddingConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
model_path: self.model_path.clone().or(base.model_path.clone()),
tokenizer_path: self.tokenizer_path.clone().or(base.tokenizer_path.clone()),
batch_size: self.batch_size.or(base.batch_size),
provider: self.provider.clone().or(base.provider.clone()),
api_key: self.api_key.clone().or(base.api_key.clone()),
base_url: self.base_url.clone().or(base.base_url.clone()),
}
}
pub fn model_path(&self) -> PathBuf {
self.model_path.clone().unwrap_or_else(|| {
Config::global_models_dir()
.join("all-MiniLM-L6-v2")
.join("model.onnx")
})
}
pub fn tokenizer_path(&self) -> PathBuf {
self.tokenizer_path.clone().unwrap_or_else(|| {
Config::global_models_dir()
.join("all-MiniLM-L6-v2")
.join("tokenizer.json")
})
}
pub fn batch_size(&self) -> usize {
self.batch_size.unwrap_or(32)
}
pub fn provider(&self) -> &str {
self.provider.as_deref().unwrap_or("onnx")
}
pub fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}
pub fn base_url(&self) -> Option<&str> {
self.base_url.as_deref()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WebConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_index: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_dir: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProxyConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listen: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http_listen: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fresh_duration_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stale_duration_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_entries: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream_timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl_jitter_percent: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_interval_secs: Option<u64>,
#[serde(default)]
pub scope: ProxyScopeConfig,
#[serde(default)]
pub cache: ProxyCacheConfig,
#[serde(default)]
pub distill: DistillConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(default)]
pub rate_limit: ProxyRateLimitConfig,
#[serde(default)]
pub retry: ProxyRetryConfig,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub upstreams: Vec<UpstreamEndpointConfig>,
#[serde(default)]
pub federated: FederatedConfig,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default)]
pub cascade: CascadeConfig,
#[serde(default)]
pub pool: ConnectionPoolConfig,
#[serde(default)]
pub circuit_breaker: ProxyCircuitBreakerConfig,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<AgentConfig>,
#[serde(default)]
pub cdc: CdcConfig,
#[serde(default)]
pub peer: PeerConfig,
#[serde(default)]
pub socket_tuning: SocketTuningConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shutdown_timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_global_connections: Option<usize>,
#[serde(default)]
pub web_ui: WebUiConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct WebUiConfig {
#[serde(default)]
pub enabled: bool,
}
impl WebUiConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
enabled: self.enabled || base.enabled,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub id: String,
pub api_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_context: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_contexts: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority_class: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_limit_rps: Option<u32>,
#[serde(default = "default_true")]
pub enabled: bool,
}
impl AgentConfig {
pub fn can_access_context(&self, context: &str) -> bool {
self.allowed_contexts.is_empty() || self.allowed_contexts.iter().any(|c| c == context)
}
pub fn priority_class(&self) -> u32 {
self.priority_class.unwrap_or(0)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpstreamEndpointConfig {
pub id: String,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weight: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_poll_interval_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub table: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedding_column: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_column: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub metadata_columns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub distance_metric: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dimensions: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub search_fields: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub return_fields: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
}
impl UpstreamEndpointConfig {
pub fn timeout_secs(&self) -> u64 {
self.timeout_secs.unwrap_or(30)
}
pub fn weight(&self) -> u32 {
self.weight.unwrap_or(1)
}
pub fn priority(&self) -> u32 {
self.priority.unwrap_or(0)
}
pub fn enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
pub fn version_endpoint(&self) -> Option<&str> {
self.version_endpoint.as_deref()
}
pub fn version_poll_interval_secs(&self) -> u64 {
self.version_poll_interval_secs.unwrap_or(60)
}
pub fn has_version_polling(&self) -> bool {
self.version_endpoint.is_some()
}
pub fn upstream_type(&self) -> Option<&str> {
self.upstream_type.as_deref()
}
pub fn query_mode(&self) -> Option<&str> {
self.query_mode.as_deref()
}
pub fn is_pgvector(&self) -> bool {
self.upstream_type.as_deref() == Some("pgvector")
}
pub fn is_experimental(&self) -> bool {
match self.upstream_type.as_deref() {
Some(t) => is_experimental_upstream_type(t),
None => false,
}
}
pub fn distance_metric(&self) -> &str {
self.distance_metric.as_deref().unwrap_or("cosine")
}
}
pub const EXPERIMENTAL_UPSTREAM_TYPES: &[&str] = &["pinecone", "milvus"];
pub fn is_experimental_upstream_type(upstream_type: &str) -> bool {
EXPERIMENTAL_UPSTREAM_TYPES.contains(&upstream_type)
}
impl UpstreamEndpointConfig {
pub fn validate(&self) -> std::result::Result<(), String> {
if let Some(ref ut) = self.upstream_type {
let valid = [
"elasticsearch",
"opensearch",
"qdrant",
"pinecone",
"milvus",
"pgvector",
"meilisearch",
];
if !valid.contains(&ut.as_str()) {
return Err(format!(
"upstream '{}': invalid upstream_type '{}', expected one of: {}",
self.id,
ut,
valid.join(", ")
));
}
}
if let Some(ref qm) = self.query_mode {
let valid = ["text_native", "vector_only", "unknown"];
if !valid.contains(&qm.as_str()) {
return Err(format!(
"upstream '{}': invalid query_mode '{}', expected one of: {}",
self.id,
qm,
valid.join(", ")
));
}
}
if self.is_pgvector() && self.table.is_none() {
return Err(format!(
"upstream '{}': pgvector upstream requires 'table' field",
self.id
));
}
if let Some(ref dm) = self.distance_metric {
let valid = ["cosine", "l2", "inner_product"];
if !valid.contains(&dm.as_str()) {
return Err(format!(
"upstream '{}': invalid distance_metric '{}', expected one of: {}",
self.id,
dm,
valid.join(", ")
));
}
}
Ok(())
}
pub fn resolve_api_key(&self) -> Result<Option<String>> {
match &self.api_key {
None => Ok(None),
Some(raw) => {
let resolved = resolve_env_ref(raw).ok_or_else(|| {
ConproxyError::ConfigValidation(format!(
"upstream '{}': api_key references undefined env var: {raw}",
self.id
))
})?;
if resolved.is_empty() {
Ok(None)
} else {
Ok(Some(resolved))
}
}
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AdvancedSecurityConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature_algorithm: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_pinning: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replay_detection: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replay_window_seconds: Option<u64>,
}
impl AdvancedSecurityConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
enabled: self.enabled.or(base.enabled),
signature_algorithm: self
.signature_algorithm
.clone()
.or_else(|| base.signature_algorithm.clone()),
tls_pinning: self.tls_pinning.or(base.tls_pinning),
replay_detection: self.replay_detection.or(base.replay_detection),
replay_window_seconds: self.replay_window_seconds.or(base.replay_window_seconds),
}
}
pub fn enabled(&self) -> bool {
self.enabled.unwrap_or(false)
}
pub fn tls_pinning(&self) -> bool {
self.tls_pinning.unwrap_or(false)
}
pub fn replay_detection(&self) -> bool {
self.replay_detection.unwrap_or(false)
}
pub fn replay_window_seconds(&self) -> u64 {
self.replay_window_seconds.unwrap_or(300)
}
pub fn validate(&self) -> std::result::Result<(), String> {
if let Some(ref sig) = self.signature_algorithm {
let valid = ["hmac-sha256", "blake3"];
if !valid.contains(&sig.as_str()) {
return Err(format!(
"advanced.signature_algorithm '{}' invalid, expected one of: {}",
sig,
valid.join(", ")
));
}
}
if let Some(ref val) = self.replay_window_seconds {
if *val == 0 {
return Err("advanced.replay_window_seconds must be > 0".into());
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecurityConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(default)]
pub rate_limit: ProxyRateLimitConfig,
#[serde(default)]
pub advanced: AdvancedSecurityConfig,
}
impl SecurityConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
api_key: self.api_key.clone().or_else(|| base.api_key.clone()),
rate_limit: self.rate_limit.merge_with(&base.rate_limit),
advanced: self.advanced.merge_with(&base.advanced),
}
}
pub fn validate(&self) -> std::result::Result<(), String> {
if let Some(ref key) = self.api_key {
if key.is_empty() {
return Err("security.api_key must not be empty".into());
}
}
self.advanced.validate()?;
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CdcConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub buffer_size: Option<usize>,
}
impl CdcConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
enabled: self.enabled.or(base.enabled),
buffer_size: self.buffer_size.or(base.buffer_size),
}
}
pub fn enabled(&self) -> bool {
self.enabled.unwrap_or(false)
}
pub fn buffer_size(&self) -> usize {
self.buffer_size.unwrap_or(10_000)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PeerConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub peers: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reconnect_interval_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fetch_wait_timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot_on_join: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ready_threshold: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot_batch_size: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shared_secret: Option<String>,
}
impl PeerConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
enabled: self.enabled.or(base.enabled),
node_id: self.node_id.clone().or_else(|| base.node_id.clone()),
peers: if self.peers.is_empty() {
base.peers.clone()
} else {
self.peers.clone()
},
reconnect_interval_ms: self.reconnect_interval_ms.or(base.reconnect_interval_ms),
fetch_wait_timeout_ms: self.fetch_wait_timeout_ms.or(base.fetch_wait_timeout_ms),
snapshot_on_join: self.snapshot_on_join.or(base.snapshot_on_join),
ready_threshold: self.ready_threshold.or(base.ready_threshold),
snapshot_batch_size: self.snapshot_batch_size.or(base.snapshot_batch_size),
shared_secret: self
.shared_secret
.clone()
.or_else(|| base.shared_secret.clone()),
}
}
pub fn enabled(&self) -> bool {
self.enabled.unwrap_or(false)
}
pub fn node_id(&self) -> String {
self.node_id.clone().unwrap_or_else(|| {
std::env::var("HOSTNAME").unwrap_or_else(|_| format!("node-{}", std::process::id()))
})
}
pub fn peers(&self) -> &[String] {
&self.peers
}
pub fn reconnect_interval_ms(&self) -> u64 {
self.reconnect_interval_ms.unwrap_or(5000)
}
pub fn fetch_wait_timeout_ms(&self) -> u64 {
self.fetch_wait_timeout_ms.unwrap_or(5000)
}
pub fn snapshot_on_join(&self) -> bool {
self.snapshot_on_join.unwrap_or(true)
}
pub fn ready_threshold(&self) -> f64 {
self.ready_threshold.unwrap_or(0.8)
}
pub fn snapshot_batch_size(&self) -> usize {
self.snapshot_batch_size.unwrap_or(100)
}
pub fn shared_secret_raw(&self) -> Option<&str> {
self.shared_secret.as_deref()
}
pub fn resolve_shared_secret(&self) -> std::result::Result<Option<String>, String> {
match &self.shared_secret {
None => Ok(None),
Some(raw) => {
let raw = raw.trim();
if raw.is_empty() {
return Err("peer.shared_secret must not be empty when set".to_string());
}
match resolve_env_ref(raw) {
Some(v) => Ok(Some(v)),
None => Err(format!(
"peer.shared_secret references undefined env var: {raw}"
)),
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyCircuitBreakerConfig {
#[serde(default = "default_cb_failure_threshold")]
pub failure_threshold: u32,
#[serde(default = "default_cb_success_threshold")]
pub success_threshold: u32,
#[serde(default = "default_cb_open_duration_secs")]
pub open_duration_secs: u64,
#[serde(default = "default_cb_failure_window_secs")]
pub failure_window_secs: u64,
}
fn default_cb_failure_threshold() -> u32 {
25
}
fn default_cb_success_threshold() -> u32 {
2
}
fn default_cb_open_duration_secs() -> u64 {
30
}
fn default_cb_failure_window_secs() -> u64 {
60
}
impl Default for ProxyCircuitBreakerConfig {
fn default() -> Self {
Self {
failure_threshold: default_cb_failure_threshold(),
success_threshold: default_cb_success_threshold(),
open_duration_secs: default_cb_open_duration_secs(),
failure_window_secs: default_cb_failure_window_secs(),
}
}
}
impl ProxyCircuitBreakerConfig {
pub fn to_circuit_breaker_config(&self) -> CircuitBreakerConfig {
CircuitBreakerConfig {
failure_threshold: self.failure_threshold,
success_threshold: self.success_threshold,
open_duration: Duration::from_secs(self.open_duration_secs),
failure_window: Duration::from_secs(self.failure_window_secs),
}
}
pub fn validate(&self) -> std::result::Result<(), String> {
if self.failure_threshold == 0 {
return Err("circuit_breaker.failure_threshold must be > 0".into());
}
if self.success_threshold == 0 {
return Err("circuit_breaker.success_threshold must be > 0".into());
}
if self.open_duration_secs == 0 {
return Err("circuit_breaker.open_duration_secs must be > 0".into());
}
if self.failure_window_secs == 0 {
return Err("circuit_breaker.failure_window_secs must be > 0".into());
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SocketTuningConfig {
#[serde(default = "default_true")]
pub tcp_nodelay: bool,
#[serde(default = "SocketTuningConfig::default_keepalive_secs")]
pub tcp_keepalive_secs: u64,
#[serde(default = "SocketTuningConfig::default_keepalive_interval")]
pub tcp_keepalive_interval: u64,
#[serde(default = "SocketTuningConfig::default_keepalive_probes")]
pub tcp_keepalive_probes: u32,
#[serde(default = "SocketTuningConfig::default_listen_backlog")]
pub listen_backlog: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub send_buffer_size: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recv_buffer_size: Option<usize>,
#[serde(default = "SocketTuningConfig::default_defer_accept_secs")]
pub defer_accept_secs: i32,
#[serde(default = "SocketTuningConfig::default_user_timeout_ms")]
pub user_timeout_ms: u32,
#[serde(default = "default_true")]
pub reuse_port: bool,
#[serde(default = "SocketTuningConfig::default_upstream_pool_idle_timeout")]
pub upstream_pool_idle_timeout_secs: u64,
#[serde(default = "SocketTuningConfig::default_upstream_pool_max_idle")]
pub upstream_pool_max_idle: usize,
}
impl Default for SocketTuningConfig {
fn default() -> Self {
Self {
tcp_nodelay: true,
tcp_keepalive_secs: 60,
tcp_keepalive_interval: 15,
tcp_keepalive_probes: 5,
listen_backlog: 4096,
send_buffer_size: None,
recv_buffer_size: None,
defer_accept_secs: 5,
user_timeout_ms: 30_000,
reuse_port: true,
upstream_pool_idle_timeout_secs: 90,
upstream_pool_max_idle: 32,
}
}
}
impl SocketTuningConfig {
fn default_keepalive_secs() -> u64 {
60
}
fn default_keepalive_interval() -> u64 {
15
}
fn default_keepalive_probes() -> u32 {
5
}
fn default_listen_backlog() -> u32 {
4096
}
fn default_defer_accept_secs() -> i32 {
5
}
fn default_user_timeout_ms() -> u32 {
30_000
}
fn default_upstream_pool_idle_timeout() -> u64 {
90
}
fn default_upstream_pool_max_idle() -> usize {
32
}
fn merge_with(&self, _base: &Self) -> Self {
self.clone()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProxyRateLimitConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requests_per_second: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub burst_size: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProxyRetryConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_retries: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_delay_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_delay_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backoff_multiplier: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_network_error: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_timeout: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_server_error: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_rate_limited: Option<bool>,
}
impl ProxyRateLimitConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
enabled: self.enabled.or(base.enabled),
requests_per_second: self.requests_per_second.or(base.requests_per_second),
burst_size: self.burst_size.or(base.burst_size),
}
}
pub fn enabled(&self) -> bool {
self.enabled.unwrap_or(false)
}
pub fn requests_per_second(&self) -> u32 {
self.requests_per_second.unwrap_or(100)
}
pub fn burst_size(&self) -> u32 {
self.burst_size.unwrap_or(50)
}
pub fn validate(&self) -> std::result::Result<(), String> {
if let Some(ref val) = self.requests_per_second {
if *val == 0 {
return Err("rate_limit.requests_per_second must be > 0".into());
}
}
if let Some(ref val) = self.burst_size {
if *val == 0 {
return Err("rate_limit.burst_size must be > 0".into());
}
}
Ok(())
}
}
impl ProxyRetryConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
enabled: self.enabled.or(base.enabled),
max_retries: self.max_retries.or(base.max_retries),
initial_delay_ms: self.initial_delay_ms.or(base.initial_delay_ms),
max_delay_ms: self.max_delay_ms.or(base.max_delay_ms),
backoff_multiplier: self.backoff_multiplier.or(base.backoff_multiplier),
on_network_error: self.on_network_error.or(base.on_network_error),
on_timeout: self.on_timeout.or(base.on_timeout),
on_server_error: self.on_server_error.or(base.on_server_error),
on_rate_limited: self.on_rate_limited.or(base.on_rate_limited),
}
}
pub fn enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
pub fn max_retries(&self) -> u32 {
self.max_retries.unwrap_or(3)
}
pub fn initial_delay_ms(&self) -> u64 {
self.initial_delay_ms.unwrap_or(100)
}
pub fn max_delay_ms(&self) -> u64 {
self.max_delay_ms.unwrap_or(10000)
}
pub fn backoff_multiplier(&self) -> f64 {
self.backoff_multiplier.unwrap_or(2.0)
}
pub fn on_network_error(&self) -> bool {
self.on_network_error.unwrap_or(true)
}
pub fn on_timeout(&self) -> bool {
self.on_timeout.unwrap_or(true)
}
pub fn on_server_error(&self) -> bool {
self.on_server_error.unwrap_or(true)
}
pub fn on_rate_limited(&self) -> bool {
self.on_rate_limited.unwrap_or(true)
}
pub fn validate(&self) -> std::result::Result<(), String> {
if let Some(ref max) = self.max_delay_ms {
if *max == 0 {
return Err("retry.max_delay_ms must be > 0".into());
}
if let Some(ref initial) = self.initial_delay_ms {
if *max < *initial {
return Err("retry.max_delay_ms must be >= retry.initial_delay_ms".into());
}
}
}
if let Some(ref val) = self.backoff_multiplier {
if *val < 1.0 {
return Err("retry.backoff_multiplier must be >= 1.0".into());
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WeightedPhrase {
pub text: String,
#[serde(default = "default_phrase_weight")]
pub weight: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_similarity: Option<f32>,
}
fn default_phrase_weight() -> f32 {
1.0
}
impl WeightedPhrase {
#[must_use]
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
weight: 1.0,
min_similarity: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProxyScopeConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub weighted_phrases: Vec<WeightedPhrase>,
#[serde(default, alias = "phrases", skip_serializing_if = "Vec::is_empty")]
pub seeds: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(
default,
alias = "min_similarity",
skip_serializing_if = "Option::is_none"
)]
pub min_seed_similarity: Option<f32>,
#[serde(
default,
alias = "scope_weight",
skip_serializing_if = "Option::is_none"
)]
pub seed_weight: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lexical_weight: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embed_band: Option<[f32; 2]>,
}
impl ProxyScopeConfig {
fn merge_with(&self, base: &Self) -> Self {
let weighted_phrases = if self.weighted_phrases.is_empty() {
base.weighted_phrases.clone()
} else {
self.weighted_phrases.clone()
};
let seeds = if self.seeds.is_empty() {
base.seeds.clone()
} else {
self.seeds.clone()
};
Self {
weighted_phrases,
seeds,
mode: self.mode.clone().or_else(|| base.mode.clone()),
min_seed_similarity: self.min_seed_similarity.or(base.min_seed_similarity),
seed_weight: self.seed_weight.or(base.seed_weight),
query_prefix: self
.query_prefix
.clone()
.or_else(|| base.query_prefix.clone()),
lexical_weight: self.lexical_weight.or(base.lexical_weight),
embed_band: self.embed_band.or(base.embed_band),
}
}
#[must_use]
pub fn effective_phrases(&self) -> Vec<WeightedPhrase> {
if !self.weighted_phrases.is_empty() {
return self.weighted_phrases.clone();
}
self.seeds
.iter()
.filter(|s| !s.is_empty())
.map(WeightedPhrase::new)
.collect()
}
#[must_use]
pub fn phrase_texts(&self) -> Vec<String> {
self.effective_phrases()
.into_iter()
.map(|p| p.text)
.collect()
}
pub fn mode(&self) -> &str {
self.mode.as_deref().unwrap_or("filter")
}
pub fn min_seed_similarity(&self) -> f32 {
self.min_seed_similarity.unwrap_or(0.25)
}
#[must_use]
pub fn min_similarity(&self) -> f32 {
self.min_seed_similarity()
}
pub fn seed_weight(&self) -> f32 {
self.seed_weight.unwrap_or(0.3)
}
#[must_use]
pub fn scope_weight(&self) -> f32 {
self.seed_weight()
}
#[must_use]
pub fn lexical_weight(&self) -> f32 {
self.lexical_weight.unwrap_or(0.5)
}
#[must_use]
pub fn embed_band(&self) -> [f32; 2] {
self.embed_band.unwrap_or([0.1, 0.55])
}
pub fn validate(&self) -> std::result::Result<(), String> {
if let Some(ref mode) = self.mode {
let lower = mode.to_lowercase();
let valid = ["filter", "rerank", "boost"];
if !valid.contains(&lower.as_str()) {
return Err(format!(
"scope.mode '{}' invalid, expected one of: {}",
mode,
valid.join(", ")
));
}
}
if let Some(ref val) = self.min_seed_similarity {
if !(0.0..=1.0).contains(val) {
return Err(
"scope.min_similarity / min_seed_similarity must be between 0.0 and 1.0".into(),
);
}
}
if let Some(ref val) = self.seed_weight {
if !(0.0..=1.0).contains(val) {
return Err("scope.scope_weight / seed_weight must be between 0.0 and 1.0".into());
}
}
if let Some(ref val) = self.lexical_weight {
if !(0.0..=1.0).contains(val) {
return Err("scope.lexical_weight must be between 0.0 and 1.0".into());
}
}
if let Some([lo, hi]) = self.embed_band {
if !(0.0..=1.0).contains(&lo) || !(0.0..=1.0).contains(&hi) {
return Err("scope.embed_band values must be between 0.0 and 1.0".into());
}
if lo > hi {
return Err("scope.embed_band lo must be <= hi".into());
}
}
for (i, p) in self.weighted_phrases.iter().enumerate() {
if p.text.trim().is_empty() {
return Err(format!(
"scope.weighted_phrases[{i}].text must be non-empty"
));
}
if !p.weight.is_finite() || p.weight < 0.0 {
return Err(format!(
"scope.weighted_phrases[{i}].weight must be finite and >= 0"
));
}
if let Some(ms) = p.min_similarity {
if !(0.0..=1.0).contains(&ms) {
return Err(format!(
"scope.weighted_phrases[{i}].min_similarity must be between 0.0 and 1.0"
));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProxyCacheConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_memory_mb: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_entry_size_kb: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub eviction_policy: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_ttl_5xx_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_ttl_timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_ttl_connection_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub normalized_matching: Option<bool>,
#[serde(default)]
pub per_upstream: PerUpstreamCacheConfig,
#[serde(default)]
pub semantic: SemanticCacheSettingsConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SemanticCacheSettingsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub similarity_threshold: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_entries: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_unsafe_threshold: Option<bool>,
}
pub const SAFE_SIMILARITY_THRESHOLD_FLOOR: f32 = 0.85;
impl SemanticCacheSettingsConfig {
pub fn enabled(&self) -> bool {
self.enabled.unwrap_or(false)
}
pub fn similarity_threshold(&self) -> f32 {
self.similarity_threshold.unwrap_or(0.92)
}
pub fn max_entries(&self) -> usize {
self.max_entries.unwrap_or(10_000)
}
pub fn allow_unsafe_threshold(&self) -> bool {
self.allow_unsafe_threshold.unwrap_or(false)
}
pub fn validate(&self) -> std::result::Result<(), String> {
if let Some(t) = self.similarity_threshold {
if !(0.0..=1.0).contains(&t) {
return Err(format!(
"cache.semantic.similarity_threshold must be in [0.0, 1.0], got {t}"
));
}
if self.enabled()
&& t < SAFE_SIMILARITY_THRESHOLD_FLOOR
&& !self.allow_unsafe_threshold()
{
return Err(format!(
"cache.semantic.similarity_threshold {t} is below the safe floor {}. \
Below this, bench-hitrate-sem shows false-hit 34–88%. \
Set allow_unsafe_threshold = true to override (discouraged).",
SAFE_SIMILARITY_THRESHOLD_FLOOR
));
}
}
Ok(())
}
pub fn merge_with(&self, base: &Self) -> Self {
Self {
enabled: self.enabled.or(base.enabled),
similarity_threshold: self.similarity_threshold.or(base.similarity_threshold),
max_entries: self.max_entries.or(base.max_entries),
allow_unsafe_threshold: self.allow_unsafe_threshold.or(base.allow_unsafe_threshold),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DistillConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_process_cmd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_stale: Option<bool>,
}
impl DistillConfig {
pub fn format(&self) -> &str {
self.format.as_deref().unwrap_or("md")
}
pub fn include_stale(&self) -> bool {
self.include_stale.unwrap_or(false)
}
pub fn validate(&self) -> std::result::Result<(), String> {
if let Some(ref fmt) = self.format {
if !matches!(fmt.as_str(), "md" | "json" | "both") {
return Err(format!(
"proxy.distill.format must be 'md', 'json', or 'both', got '{}'",
fmt
));
}
}
Ok(())
}
pub fn merge_with(&self, base: &Self) -> Self {
Self {
output_dir: self.output_dir.clone().or_else(|| base.output_dir.clone()),
post_process_cmd: self
.post_process_cmd
.clone()
.or_else(|| base.post_process_cmd.clone()),
format: self.format.clone().or_else(|| base.format.clone()),
include_stale: self.include_stale.or(base.include_stale),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PerUpstreamCacheConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_entries_per_upstream: Option<usize>,
}
impl PerUpstreamCacheConfig {
pub fn enabled(&self) -> bool {
self.enabled.unwrap_or(false)
}
pub fn max_entries_per_upstream(&self) -> usize {
self.max_entries_per_upstream.unwrap_or(500)
}
pub fn validate(&self) -> std::result::Result<(), String> {
if let Some(ref val) = self.max_entries_per_upstream {
if *val == 0 {
return Err("cache.per_upstream.max_entries_per_upstream must be > 0".into());
}
}
Ok(())
}
}
impl ProxyCacheConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
max_memory_mb: self.max_memory_mb.or(base.max_memory_mb),
max_entry_size_kb: self.max_entry_size_kb.or(base.max_entry_size_kb),
eviction_policy: self
.eviction_policy
.clone()
.or_else(|| base.eviction_policy.clone()),
error_ttl_5xx_secs: self.error_ttl_5xx_secs.or(base.error_ttl_5xx_secs),
error_ttl_timeout_secs: self.error_ttl_timeout_secs.or(base.error_ttl_timeout_secs),
error_ttl_connection_secs: self
.error_ttl_connection_secs
.or(base.error_ttl_connection_secs),
normalized_matching: self.normalized_matching.or(base.normalized_matching),
per_upstream: PerUpstreamCacheConfig {
enabled: self.per_upstream.enabled.or(base.per_upstream.enabled),
max_entries_per_upstream: self
.per_upstream
.max_entries_per_upstream
.or(base.per_upstream.max_entries_per_upstream),
},
semantic: self.semantic.merge_with(&base.semantic),
}
}
pub fn max_memory_mb(&self) -> usize {
self.max_memory_mb.unwrap_or(256)
}
pub fn max_entry_size_kb(&self) -> usize {
self.max_entry_size_kb.unwrap_or(512)
}
pub fn eviction_policy(&self) -> &str {
self.eviction_policy.as_deref().unwrap_or("lru")
}
pub fn normalized_matching(&self) -> bool {
self.normalized_matching.unwrap_or(false)
}
pub fn per_upstream(&self) -> &PerUpstreamCacheConfig {
&self.per_upstream
}
pub fn semantic(&self) -> &SemanticCacheSettingsConfig {
&self.semantic
}
pub fn validate(&self) -> std::result::Result<(), String> {
if let Some(ref policy) = self.eviction_policy {
let valid = ["lru", "lfu", "ttl_first"];
if !valid.contains(&policy.as_str()) {
return Err(format!(
"cache.eviction_policy '{}' invalid, expected one of: {}",
policy,
valid.join(", ")
));
}
}
if let Some(ref val) = self.max_entry_size_kb {
if *val == 0 {
return Err("cache.max_entry_size_kb must be > 0".into());
}
}
self.per_upstream.validate()?;
self.semantic.validate()?;
Ok(())
}
}
impl WebConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
auto_index: self.auto_index.or(base.auto_index),
content_dir: self.content_dir.clone().or(base.content_dir.clone()),
}
}
pub fn auto_index(&self) -> bool {
self.auto_index.unwrap_or(false)
}
pub fn content_dir(&self) -> &str {
self.content_dir.as_deref().unwrap_or("web")
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ContextConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paths: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warm_interval: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warm_limit: Option<usize>,
}
impl ContextConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
paths: self.paths.clone().or_else(|| base.paths.clone()),
warm_interval: self.warm_interval.or(base.warm_interval),
warm_limit: self.warm_limit.or(base.warm_limit),
}
}
pub fn paths(&self) -> Vec<String> {
self.paths
.clone()
.unwrap_or_else(|| vec!["packages/**/*.md".to_string()])
}
pub fn warm_interval(&self) -> u64 {
self.warm_interval.unwrap_or(300)
}
pub fn warm_limit(&self) -> usize {
self.warm_limit.unwrap_or(1000)
}
}
impl ProxyConfig {
fn merge_with(&self, base: &Self) -> Self {
Self {
listen: self.listen.clone().or_else(|| base.listen.clone()),
fresh_duration_secs: self.fresh_duration_secs.or(base.fresh_duration_secs),
stale_duration_secs: self.stale_duration_secs.or(base.stale_duration_secs),
max_entries: self.max_entries.or(base.max_entries),
upstream_url: self
.upstream_url
.clone()
.or_else(|| base.upstream_url.clone()),
upstream_timeout_secs: self.upstream_timeout_secs.or(base.upstream_timeout_secs),
ttl_jitter_percent: self.ttl_jitter_percent.or(base.ttl_jitter_percent),
refresh_interval_secs: self.refresh_interval_secs.or(base.refresh_interval_secs),
scope: self.scope.merge_with(&base.scope),
cache: self.cache.merge_with(&base.cache),
distill: self.distill.merge_with(&base.distill),
api_key: self.api_key.clone().or_else(|| base.api_key.clone()),
rate_limit: self.rate_limit.merge_with(&base.rate_limit),
retry: self.retry.merge_with(&base.retry),
upstreams: if self.upstreams.is_empty() {
base.upstreams.clone()
} else {
self.upstreams.clone()
},
federated: self.federated.merge_with(&base.federated),
security: self.security.merge_with(&base.security),
cascade: self.cascade.clone(),
pool: self.pool.clone(),
circuit_breaker: self.circuit_breaker.clone(),
agents: if self.agents.is_empty() {
base.agents.clone()
} else {
self.agents.clone()
},
http_listen: self
.http_listen
.clone()
.or_else(|| base.http_listen.clone()),
cdc: self.cdc.merge_with(&base.cdc),
peer: self.peer.merge_with(&base.peer),
socket_tuning: self.socket_tuning.merge_with(&base.socket_tuning),
shutdown_timeout_secs: self.shutdown_timeout_secs.or(base.shutdown_timeout_secs),
max_global_connections: self.max_global_connections.or(base.max_global_connections),
web_ui: self.web_ui.merge_with(&base.web_ui),
}
}
pub fn listen(&self) -> &str {
self.listen.as_deref().unwrap_or("127.0.0.1:9999")
}
pub fn http_listen_addr(&self) -> String {
if let Some(ref addr) = self.http_listen {
return addr.clone();
}
let grpc_addr = self.listen.as_deref().unwrap_or("127.0.0.1:9999");
if let Ok(addr) = grpc_addr.parse::<std::net::SocketAddr>() {
format!("{}:{}", addr.ip(), addr.port().saturating_add(1))
} else {
"127.0.0.1:10000".to_string()
}
}
pub fn fresh_duration_secs(&self) -> u64 {
self.fresh_duration_secs.unwrap_or(300)
}
pub fn stale_duration_secs(&self) -> u64 {
self.stale_duration_secs.unwrap_or(3600)
}
pub fn max_entries(&self) -> usize {
self.max_entries.unwrap_or(10000)
}
pub fn upstream_url(&self) -> Option<&str> {
self.upstream_url.as_deref()
}
pub fn upstream_timeout_secs(&self) -> u64 {
self.upstream_timeout_secs.unwrap_or(30)
}
pub fn ttl_jitter_percent(&self) -> f32 {
self.ttl_jitter_percent.unwrap_or(0.1)
}
pub fn refresh_interval_secs(&self) -> u64 {
self.refresh_interval_secs.unwrap_or(60)
}
#[must_use]
pub fn scope_seeds(&self) -> Vec<String> {
self.scope.phrase_texts()
}
pub fn scope_seeds_mut(&mut self) -> &mut Vec<String> {
if !self.scope.weighted_phrases.is_empty() {
let texts: Vec<String> = self
.scope
.weighted_phrases
.drain(..)
.map(|p| p.text)
.collect();
if self.scope.seeds.is_empty() {
self.scope.seeds = texts;
}
}
&mut self.scope.seeds
}
pub fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}
pub fn requires_auth(&self) -> bool {
self.api_key.is_some()
}
pub fn rate_limit(&self) -> &ProxyRateLimitConfig {
&self.rate_limit
}
pub fn retry(&self) -> &ProxyRetryConfig {
&self.retry
}
pub fn upstreams(&self) -> &[UpstreamEndpointConfig] {
&self.upstreams
}
pub fn enabled_upstreams(&self) -> Vec<&UpstreamEndpointConfig> {
self.upstreams.iter().filter(|u| u.enabled()).collect()
}
pub fn has_multiple_upstreams(&self) -> bool {
self.upstreams.len() > 1
}
pub fn agents(&self) -> &[AgentConfig] {
&self.agents
}
pub fn has_agents(&self) -> bool {
!self.agents.is_empty()
}
pub fn enabled_agents(&self) -> Vec<&AgentConfig> {
self.agents.iter().filter(|a| a.enabled).collect()
}
pub fn normalize_upstreams(&mut self) -> bool {
if self.upstreams.is_empty() {
if let Some(ref url) = self.upstream_url {
tracing::warn!(
"proxy.upstream_url is deprecated; \
use [[proxy.upstreams]] with id and url instead"
);
self.upstreams.push(UpstreamEndpointConfig {
id: "default".to_string(),
url: url.clone(),
timeout_secs: self.upstream_timeout_secs,
weight: None,
priority: None,
max_concurrent: None,
enabled: None,
version_endpoint: None,
version_poll_interval_secs: None,
upstream_type: None,
query_mode: None,
table: None,
embedding_column: None,
content_column: None,
metadata_columns: Vec::new(),
distance_metric: None,
dimensions: None,
index: None,
search_fields: Vec::new(),
return_fields: Vec::new(),
api_key: None,
});
return true;
}
}
false
}
pub fn apply_env_overrides(&mut self) -> usize {
let mut count: usize = 0;
let host = std::env::var("CONPROXY_HOST").ok();
let port = std::env::var("CONPROXY_PORT").ok();
if host.is_some() || port.is_some() {
let current = self
.listen
.clone()
.unwrap_or_else(|| "127.0.0.1:3000".to_string());
let parts: Vec<&str> = current.rsplitn(2, ':').collect();
let cur_port = parts.first().unwrap_or(&"3000");
let cur_host = if let Some(host_part) = parts.get(1) {
host_part
} else {
"127.0.0.1"
};
let new_host = host.as_deref().unwrap_or(cur_host);
let new_port = port.as_deref().unwrap_or(cur_port);
self.listen = Some(format!("{}:{}", new_host, new_port));
count = count.saturating_add(1);
}
if let Ok(key) = std::env::var("CONPROXY_API_KEY") {
self.api_key = Some(key.clone());
self.security.api_key = Some(key);
count = count.saturating_add(1);
}
if let Ok(val) = std::env::var("CONPROXY_CACHE_MAX_ENTRIES") {
if let Ok(n) = val.parse::<usize>() {
self.max_entries = Some(n);
count = count.saturating_add(1);
}
}
for upstream in &mut self.upstreams {
let env_key = format!(
"CONPROXY_UPSTREAM_{}_URL",
upstream.id.to_uppercase().replace('-', "_")
);
if let Ok(url) = std::env::var(&env_key) {
upstream.url = url;
count = count.saturating_add(1);
}
}
count
}
pub fn cdc(&self) -> &CdcConfig {
&self.cdc
}
pub fn peer(&self) -> &PeerConfig {
&self.peer
}
pub fn cdc_enabled(&self) -> bool {
self.cdc.enabled() || self.peer.enabled()
}
pub fn shutdown_timeout_secs(&self) -> u64 {
self.shutdown_timeout_secs.unwrap_or(30)
}
pub fn max_global_connections(&self) -> usize {
self.max_global_connections.unwrap_or(1000)
}
}
impl Config {
pub fn load() -> Result<Self> {
let global = Self::load_global()?;
let local = Self::load_local()?;
let merged = match (&global, &local) {
(Some(g), Some(l)) => g.merge_with(l),
(Some(g), None) => g.clone(),
(None, Some(l)) => l.clone(),
(None, None) => ConfigFile::default_local(),
};
merged.validate().map_err(ConproxyError::ConfigValidation)?;
let local_root = if local.is_some() {
Some(Self::find_local_root()?)
} else {
None
};
Ok(Self {
config: merged,
local_root,
})
}
pub fn reload() -> Result<Self> {
Self::load()
}
pub fn load_from(path: &str) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: ConfigFile = toml::from_str(&content)?;
config.validate().map_err(ConproxyError::ConfigValidation)?;
Ok(Self {
config,
local_root: PathBuf::from(path).parent().map(|p| p.to_path_buf()),
})
}
pub fn load_global() -> Result<Option<ConfigFile>> {
let path = Self::global_config_path();
if path.exists() {
let content = std::fs::read_to_string(&path)?;
let config: ConfigFile = toml::from_str(&content)?;
Ok(Some(config))
} else {
Ok(None)
}
}
pub fn load_local() -> Result<Option<ConfigFile>> {
let path = Self::local_config_path();
if path.exists() {
let content = std::fs::read_to_string(&path)?;
let config: ConfigFile = toml::from_str(&content)?;
Ok(Some(config))
} else {
Ok(None)
}
}
fn find_local_root() -> Result<PathBuf> {
let cwd = std::env::current_dir()?;
let local_dir = cwd.join(".conproxy");
if local_dir.exists() {
Ok(local_dir)
} else {
Err(ConproxyError::NotInitialized)
}
}
pub fn save(&self) -> Result<()> {
Self::ensure_local_dirs()?;
let path = Self::local_config_path();
let content = toml::to_string_pretty(&self.config)?;
std::fs::write(path, content)?;
Ok(())
}
pub fn save_global(&self) -> Result<()> {
let path = Self::global_config_path();
let content = toml::to_string_pretty(&self.config)?;
std::fs::write(path, content)?;
Ok(())
}
pub fn global_dir() -> PathBuf {
let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from));
home.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".conproxy")
}
pub fn global_config_path() -> PathBuf {
Self::global_dir().join("conproxy.toml")
}
pub fn global_models_dir() -> PathBuf {
Self::global_dir().join("models")
}
pub fn local_dir() -> PathBuf {
PathBuf::from(".conproxy")
}
pub fn local_config_path() -> PathBuf {
Self::local_dir().join("conproxy.toml")
}
pub fn conproxy_dir(&self) -> PathBuf {
self.local_root
.clone()
.unwrap_or_else(|| PathBuf::from(".conproxy"))
}
pub fn index_dir(&self) -> PathBuf {
self.conproxy_dir().join("index")
}
pub fn cache_dir(&self) -> PathBuf {
self.conproxy_dir().join("cache")
}
pub fn packages_dir(&self) -> PathBuf {
self.conproxy_dir().join("packages")
}
pub fn web_dir(&self) -> PathBuf {
self.conproxy_dir().join("web")
}
pub fn models_dir(&self) -> PathBuf {
Self::global_models_dir()
}
pub fn ensure_local_dirs() -> Result<()> {
let local_dir = Self::local_dir();
std::fs::create_dir_all(&local_dir)?;
std::fs::create_dir_all(local_dir.join("packages"))?;
std::fs::create_dir_all(local_dir.join("index"))?;
std::fs::create_dir_all(local_dir.join("cache"))?;
std::fs::create_dir_all(local_dir.join("web"))?;
let gitignore_path = local_dir.join(".gitignore");
if !gitignore_path.exists() {
std::fs::write(&gitignore_path, "cache/\n*.pid\n")?;
}
Ok(())
}
}
pub(crate) fn resolve_env_ref(s: &str) -> Option<String> {
if s.starts_with("${") && s.ends_with('}') {
#[allow(clippy::arithmetic_side_effects)]
let var = &s[2..s.len() - 1];
match std::env::var(var) {
Ok(v) if !v.is_empty() => Some(v),
_ => None,
}
} else {
Some(s.to_string())
}
}
#[cfg(test)]
mod tests;