use std::net::IpAddr;
use crate::adapter::LlmError;
const SHA256_PREFIX: &str = "@sha256:";
const SHA256_HEX_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OllamaConfig {
pub endpoint_url: String,
pub model: String,
}
impl OllamaConfig {
#[must_use]
pub fn new(endpoint_url: impl Into<String>, model: impl Into<String>) -> Self {
Self {
endpoint_url: endpoint_url.into(),
model: model.into(),
}
}
pub fn validate(&self) -> Result<(), LlmError> {
validate_config(self)
}
}
pub fn validate_config(config: &OllamaConfig) -> Result<(), LlmError> {
validate_endpoint_url(&config.endpoint_url)?;
validate_model_ref(&config.model)
}
pub fn validate_model_ref(model: &str) -> Result<(), LlmError> {
let Some((name, digest)) = model.rsplit_once(SHA256_PREFIX) else {
return Err(invalid_request(format!(
"ollama model ref must be digest-pinned with @sha256:<64 hex chars>: {model}"
)));
};
if name.is_empty() {
return Err(invalid_request(
"ollama model ref must include a model name before @sha256".to_string(),
));
}
if digest.len() != SHA256_HEX_LEN || !digest.as_bytes().iter().all(u8::is_ascii_hexdigit) {
return Err(invalid_request(format!(
"ollama model ref has invalid sha256 digest; expected 64 hex chars: {model}"
)));
}
Ok(())
}
pub fn validate_endpoint_url(endpoint_url: &str) -> Result<(), LlmError> {
let rest = if let Some(rest) = endpoint_url.strip_prefix("http://") {
rest
} else if let Some(rest) = endpoint_url.strip_prefix("https://") {
rest
} else {
return Err(invalid_request(format!(
"ollama endpoint must use http:// or https:// loopback URL: {endpoint_url}"
)));
};
let host = extract_host(rest).ok_or_else(|| {
invalid_request(format!(
"ollama endpoint must include a loopback host: {endpoint_url}"
))
})?;
if is_loopback_host(host) {
Ok(())
} else {
Err(invalid_request(format!(
"ollama endpoint host must be loopback-only; got {host}"
)))
}
}
fn extract_host(rest: &str) -> Option<&str> {
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
if authority.is_empty() || authority.contains('@') {
return None;
}
if let Some(after_open) = authority.strip_prefix('[') {
let (host, suffix) = after_open.split_once(']')?;
if suffix.is_empty() || suffix.starts_with(':') {
return Some(host);
}
return None;
}
let host = authority.split(':').next().unwrap_or_default();
if host.is_empty() {
None
} else {
Some(host)
}
}
fn is_loopback_host(host: &str) -> bool {
if host.eq_ignore_ascii_case("localhost") {
return true;
}
host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
fn invalid_request(message: String) -> LlmError {
LlmError::InvalidRequest(message)
}