use std::collections::HashSet;
use url::Url;
use super::{Config, DeviceKind, is_sha256_hex};
use crate::error::ConfigError;
impl Config {
pub(crate) fn validate(&self) -> Result<(), ConfigError> {
if self.server.key.is_empty() {
return Err(ConfigError::Validation(
"server.key must not be empty".to_string(),
));
}
if self.queue.max_depth < 1 {
return Err(ConfigError::Validation(
"queue.max_depth must be at least 1".to_string(),
));
}
self.validate_devices()?;
let endpoint_ids = self.validate_endpoints()?;
self.validate_models(&endpoint_ids)?;
self.validate_tools()?;
Ok(())
}
fn validate_tools(&self) -> Result<(), ConfigError> {
let Some(web_search) = self.web_search_config() else {
return Ok(());
};
if web_search.default_count < 1 {
return Err(ConfigError::Validation(
"tools.web_search.default_count must be at least 1".to_string(),
));
}
if web_search.max_count < 1 {
return Err(ConfigError::Validation(
"tools.web_search.max_count must be at least 1".to_string(),
));
}
if web_search.default_count > web_search.max_count {
return Err(ConfigError::Validation(
"tools.web_search.default_count must not exceed max_count".to_string(),
));
}
if web_search.max_per_host < 1 {
return Err(ConfigError::Validation(
"tools.web_search.max_per_host must be at least 1".to_string(),
));
}
validate_http_url("tools.web_search.base_url", web_search.base_url.trim())?;
if !is_valid_freshness(&web_search.default_freshness) {
return Err(ConfigError::Validation(format!(
"tools.web_search.default_freshness {:?} is not one of pd/pw/pm/py, a \
YYYY-MM-DDtoYYYY-MM-DD range, or empty",
web_search.default_freshness
)));
}
if !is_valid_safesearch(&web_search.default_safesearch) {
return Err(ConfigError::Validation(format!(
"tools.web_search.default_safesearch {:?} is not off/moderate/strict or empty",
web_search.default_safesearch
)));
}
Ok(())
}
fn validate_devices(&self) -> Result<(), ConfigError> {
let mut device_ids = HashSet::new();
for device in &self.devices {
if device.id.is_empty() {
return Err(ConfigError::Validation(
"device id must not be empty".to_string(),
));
}
if !device_ids.insert(device.id.as_str()) {
return Err(ConfigError::Validation(format!(
"duplicate device id {}",
device.id
)));
}
if let Some(concurrency) = device.concurrency
&& concurrency < 1
{
return Err(ConfigError::Validation(format!(
"device {} concurrency must be at least 1",
device.id
)));
}
match device.kind {
DeviceKind::Remote if !device.lanes.is_empty() => {
return Err(ConfigError::Validation(format!(
"remote device {} must not declare lanes",
device.id
)));
}
DeviceKind::Local if device.concurrency.is_some() => {
return Err(ConfigError::Validation(format!(
"local device {} uses lanes, not flat concurrency",
device.id
)));
}
_ => {}
}
let mut lane_ids = HashSet::new();
for lane in &device.lanes {
if lane.id.is_empty() {
return Err(ConfigError::Validation(format!(
"device {} lane id must not be empty",
device.id
)));
}
if !lane_ids.insert(lane.id.as_str()) {
return Err(ConfigError::Validation(format!(
"duplicate lane id {} on device {}",
lane.id, device.id
)));
}
if lane.concurrency < 1 {
return Err(ConfigError::Validation(format!(
"device {} lane {} concurrency must be at least 1",
device.id, lane.id
)));
}
if let Some(ref_id) = &lane.device
&& ref_id != &device.id
{
return Err(ConfigError::Validation(format!(
"lane {} device {ref_id} does not match parent device {}",
lane.id, device.id
)));
}
}
}
Ok(())
}
fn validate_endpoints(&self) -> Result<HashSet<&str>, ConfigError> {
let mut endpoint_ids = HashSet::new();
for endpoint in &self.endpoints {
if endpoint.id.trim().is_empty() {
return Err(ConfigError::Validation(
"endpoint id must not be empty".to_string(),
));
}
if !endpoint_ids.insert(endpoint.id.as_str()) {
return Err(ConfigError::Validation(format!(
"duplicate endpoint id {}",
endpoint.id
)));
}
validate_http_url(
&format!("endpoint {} base_url", endpoint.id),
endpoint.base_url.trim(),
)?;
if let Some(concurrency) = endpoint.concurrency
&& concurrency < 1
{
return Err(ConfigError::Validation(format!(
"endpoint {} concurrency must be at least 1",
endpoint.id
)));
}
if let Some(device_id) = &endpoint.device {
let device = self.devices.iter().find(|d| d.id == *device_id);
let Some(device) = device else {
return Err(ConfigError::Validation(format!(
"endpoint {} names undefined device {device_id}",
endpoint.id
)));
};
if device.kind != DeviceKind::Remote {
return Err(ConfigError::Validation(format!(
"endpoint {} references non-remote device {device_id}",
endpoint.id
)));
}
}
}
Ok(endpoint_ids)
}
fn validate_models(&self, endpoint_ids: &HashSet<&str>) -> Result<(), ConfigError> {
let mut model_names = HashSet::new();
for model in &self.models {
if !model_names.insert(model.name.as_str()) {
return Err(ConfigError::Validation(format!(
"duplicate model name {}",
model.name
)));
}
if model.name.trim().is_empty() {
return Err(ConfigError::Validation(
"model name must not be empty".to_string(),
));
}
if model.description.trim().is_empty() {
return Err(ConfigError::Validation(format!(
"model {} description must not be empty",
model.name
)));
}
if model.upstream.trim().is_empty() {
return Err(ConfigError::Validation(format!(
"model {} upstream must not be empty",
model.name
)));
}
if model.context == 0 {
return Err(ConfigError::Validation(format!(
"model {} context must be greater than zero",
model.name
)));
}
if model.default_max_tokens == Some(0) {
return Err(ConfigError::Validation(format!(
"model {} default_max_tokens must be greater than zero",
model.name
)));
}
if model.endpoints.is_empty() {
return Err(ConfigError::Validation(format!(
"model {} has no endpoints",
model.name
)));
}
let mut seen_endpoints = HashSet::new();
for endpoint in &model.endpoints {
if !endpoint_ids.contains(endpoint.as_str()) {
return Err(ConfigError::Validation(format!(
"model {} names undefined endpoint {endpoint}",
model.name
)));
}
if !seen_endpoints.insert(endpoint.as_str()) {
return Err(ConfigError::Validation(format!(
"model {} lists duplicate endpoint {endpoint}",
model.name
)));
}
}
}
self.validate_local_models(&mut model_names)
}
fn validate_local_models<'a>(
&'a self,
model_names: &mut HashSet<&'a str>,
) -> Result<(), ConfigError> {
for local_model in &self.local_models {
if local_model.name.is_empty() {
return Err(ConfigError::Validation(
"local_model name must not be empty".to_string(),
));
}
if !model_names.insert(local_model.name.as_str()) {
return Err(ConfigError::Validation(format!(
"duplicate model name {}",
local_model.name
)));
}
if local_model.description.is_empty() {
return Err(ConfigError::Validation(format!(
"local_model {} description must not be empty",
local_model.name
)));
}
if local_model.source.is_empty() {
return Err(ConfigError::Validation(format!(
"local_model {} source must not be empty",
local_model.name
)));
}
if local_model.source.starts_with("http://") {
return Err(ConfigError::Validation(format!(
"local_model {} source must use https, not plaintext http",
local_model.name
)));
}
let is_remote = local_model.source.starts_with("https://");
if is_remote {
validate_http_url(
&format!("local_model {} source", local_model.name),
&local_model.source,
)?;
if local_model.sha256.is_none() {
return Err(ConfigError::Validation(format!(
"local_model {} has a remote source and must set a sha256 pin",
local_model.name
)));
}
}
if local_model.context < 1 {
return Err(ConfigError::Validation(format!(
"local_model {} context must be at least 1",
local_model.name
)));
}
if local_model.n_predict < 1 {
return Err(ConfigError::Validation(format!(
"local_model {} n_predict must be at least 1",
local_model.name
)));
}
if local_model.cache_type_k.is_empty() || local_model.cache_type_v.is_empty() {
return Err(ConfigError::Validation(format!(
"local_model {} cache_type_k/v must not be empty",
local_model.name
)));
}
if let Some(sha) = &local_model.sha256
&& !is_sha256_hex(sha)
{
return Err(ConfigError::Validation(format!(
"local_model {} sha256 must be 64 lowercase hex characters",
local_model.name
)));
}
self.local_model_concurrency(local_model)?;
}
Ok(())
}
}
fn validate_http_url(context: &str, raw: &str) -> Result<(), ConfigError> {
let url = Url::parse(raw).map_err(|error| {
ConfigError::Validation(format!("{context} is not a valid URL: {error}"))
})?;
if !matches!(url.scheme(), "http" | "https") {
return Err(ConfigError::Validation(format!(
"{context} must use http or https, got {:?}",
url.scheme()
)));
}
if url.host_str().is_none_or(str::is_empty) {
return Err(ConfigError::Validation(format!(
"{context} must include a host"
)));
}
Ok(())
}
fn is_valid_freshness(value: &str) -> bool {
if value.is_empty() || matches!(value, "pd" | "pw" | "pm" | "py") {
return true;
}
value
.split_once("to")
.is_some_and(|(from, to)| is_iso_date(from) && is_iso_date(to))
}
fn is_iso_date(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() == 10
&& bytes[4] == b'-'
&& bytes[7] == b'-'
&& bytes
.iter()
.enumerate()
.all(|(index, byte)| index == 4 || index == 7 || byte.is_ascii_digit())
}
fn is_valid_safesearch(value: &str) -> bool {
matches!(value, "" | "off" | "moderate" | "strict")
}
#[cfg(test)]
mod tests {
use super::{is_iso_date, is_valid_freshness, is_valid_safesearch, validate_http_url};
#[test]
fn http_url_accepts_http_and_https_with_host() {
assert!(validate_http_url("ctx", "http://127.0.0.1:9").is_ok());
assert!(validate_http_url("ctx", "https://api.example.com/res/v1").is_ok());
}
#[test]
fn http_url_rejects_missing_scheme_and_bad_scheme() {
assert!(validate_http_url("ctx", "not-a-url").is_err());
assert!(validate_http_url("ctx", "ftp://example.com").is_err());
assert!(validate_http_url("ctx", "127.0.0.1:9").is_err());
}
#[test]
fn freshness_vocabulary() {
for ok in ["", "pd", "pw", "pm", "py", "2024-01-01to2024-12-31"] {
assert!(is_valid_freshness(ok), "expected {ok:?} to be valid");
}
for bad in [
"daily",
"p1",
"2024/01/01to2024/12/31",
"2024-1-1to2024-12-31",
] {
assert!(!is_valid_freshness(bad), "expected {bad:?} to be invalid");
}
}
#[test]
fn safesearch_vocabulary() {
for ok in ["", "off", "moderate", "strict"] {
assert!(is_valid_safesearch(ok));
}
for bad in ["on", "medium", "safe"] {
assert!(!is_valid_safesearch(bad));
}
}
#[test]
fn iso_date_shape() {
assert!(is_iso_date("2024-01-01"));
assert!(!is_iso_date("2024-1-01"));
assert!(!is_iso_date("2024-01-01T"));
}
}