use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::env;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct ModelConfig {
pub model_path: PathBuf,
pub model_name: String,
pub n_ctx: Option<u32>,
pub n_batch: Option<u32>,
pub n_ubatch: Option<u32>,
pub n_threads: Option<usize>,
pub n_gpu_layers: Option<u32>,
pub use_mmap: bool,
pub use_mlock: bool,
pub normalization_mode: Option<NormalizationMode>,
pub pooling_strategy: Option<PoolingStrategy>,
pub n_seq_max: Option<u32>,
pub context_size: Option<u32>,
pub enable_kv_optimization: bool,
}
impl ModelConfig {
pub fn builder() -> ModelConfigBuilder {
ModelConfigBuilder::new()
}
pub fn with_backend_detection() -> ModelConfigBuilder {
let backend = crate::backend::detect_best_backend();
let mut builder = ModelConfigBuilder::new();
if let Some(gpu_layers) = backend.recommended_gpu_layers() {
builder = builder.with_n_gpu_layers(gpu_layers);
}
builder
}
pub fn validate(&self) -> Result<()> {
if self.model_path.as_os_str().is_empty() {
return Err(Error::config("Model path cannot be empty"));
}
if !self.model_path.exists() {
return Err(Error::config(format!(
"Model file does not exist: {}",
self.model_path.display()
)));
}
let canonical = self.model_path.canonicalize().map_err(|e| {
Error::config(format!(
"Cannot resolve model path '{}': {e}",
self.model_path.display()
))
})?;
if canonical.extension().and_then(|e| e.to_str()) != Some("gguf") {
return Err(Error::config(format!(
"Model path resolves to non-GGUF file: {}",
canonical.display()
)));
}
if self.model_name.trim().is_empty() {
return Err(Error::config("Model name cannot be empty"));
}
if let Some(n_ctx) = self.n_ctx
&& n_ctx == 0
{
return Err(Error::config("Context size must be greater than 0"));
}
if let Some(context_size) = self.context_size
&& context_size == 0
{
return Err(Error::config("Context size must be greater than 0"));
}
if let Some(n_batch) = self.n_batch
&& n_batch == 0
{
return Err(Error::config("Batch size must be greater than 0"));
}
if let Some(n_ubatch) = self.n_ubatch
&& n_ubatch == 0
{
return Err(Error::config("Micro-batch size must be greater than 0"));
}
if let (Some(context_size), Some(n_batch)) = (self.context_size, self.n_batch)
&& n_batch > context_size
{
return Err(Error::config(
"Batch size (n_batch) cannot exceed context size",
));
}
if let (Some(n_batch), Some(n_ubatch)) = (self.n_batch, self.n_ubatch)
&& n_ubatch > n_batch
{
return Err(Error::config(
"Micro-batch size (n_ubatch) cannot exceed batch size (n_batch)",
));
}
if let Some(n_threads) = self.n_threads
&& n_threads == 0
{
return Err(Error::config("Number of threads must be greater than 0"));
}
if let Some(n_seq) = self.n_seq_max {
if n_seq == 0 {
return Err(Error::config("n_seq_max must be greater than 0"));
}
if n_seq > 64 {
return Err(Error::config(
"n_seq_max cannot exceed 64 (llama.cpp limit)",
));
}
}
Ok(())
}
}
impl Default for ModelConfig {
fn default() -> Self {
Self {
model_path: PathBuf::new(),
model_name: String::new(),
n_ctx: None,
n_batch: None,
n_ubatch: None,
n_threads: None,
n_gpu_layers: None,
use_mmap: true,
use_mlock: false,
normalization_mode: None,
pooling_strategy: None,
n_seq_max: None,
context_size: None,
enable_kv_optimization: false,
}
}
}
pub struct ModelConfigBuilder {
config: ModelConfig,
}
impl ModelConfigBuilder {
#[must_use]
pub fn new() -> Self {
Self {
config: ModelConfig::default(),
}
}
#[must_use]
pub fn with_model_path<P: AsRef<Path>>(mut self, path: P) -> Self {
self.config.model_path = path.as_ref().to_path_buf();
self
}
#[must_use]
pub fn with_model_name<S: Into<String>>(mut self, name: S) -> Self {
self.config.model_name = name.into();
self
}
#[must_use]
pub fn with_n_ctx(mut self, ctx: u32) -> Self {
self.config.n_ctx = Some(ctx);
self
}
#[must_use]
pub fn with_n_batch(mut self, batch: u32) -> Self {
self.config.n_batch = Some(batch);
self
}
#[must_use]
pub fn with_n_ubatch(mut self, ubatch: u32) -> Self {
self.config.n_ubatch = Some(ubatch);
self
}
#[must_use]
pub fn with_n_threads(mut self, threads: usize) -> Self {
self.config.n_threads = Some(threads);
self
}
#[must_use]
pub fn with_n_gpu_layers(mut self, layers: u32) -> Self {
self.config.n_gpu_layers = Some(layers);
self
}
#[must_use]
pub fn with_use_mmap(mut self, use_mmap: bool) -> Self {
self.config.use_mmap = use_mmap;
self
}
#[must_use]
pub fn with_use_mlock(mut self, use_mlock: bool) -> Self {
self.config.use_mlock = use_mlock;
self
}
#[must_use]
pub fn with_normalization_mode(mut self, mode: NormalizationMode) -> Self {
self.config.normalization_mode = Some(mode);
self
}
#[must_use]
pub fn with_pooling_strategy(mut self, strategy: PoolingStrategy) -> Self {
self.config.pooling_strategy = Some(strategy);
self
}
#[must_use]
pub fn with_n_seq_max(mut self, n_seq_max: u32) -> Self {
self.config.n_seq_max = Some(n_seq_max);
self
}
#[must_use]
pub fn with_context_size(mut self, context_size: u32) -> Self {
self.config.context_size = Some(context_size);
self
}
#[must_use]
pub fn with_kv_optimization(mut self, enable: bool) -> Self {
self.config.enable_kv_optimization = enable;
self
}
pub fn build(self) -> Result<ModelConfig> {
self.config.validate()?;
Ok(self.config)
}
}
impl Default for ModelConfigBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EngineConfig {
pub model_config: ModelConfig,
pub use_gpu: bool,
pub batch_size: Option<usize>,
pub max_tokens: Option<usize>,
pub memory_limit_mb: Option<usize>,
pub verbose: bool,
pub seed: Option<u32>,
pub temperature: Option<f32>,
pub cache: Option<CacheConfig>,
pub embedding: Option<EmbeddingConfig>,
}
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum PoolingStrategy {
#[default]
Mean,
Cls,
Max,
MeanSqrt,
Last,
None,
Rank,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RerankResult {
pub index: usize,
pub relevance_score: f32,
}
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum NormalizationMode {
None,
MaxAbs,
#[default]
L2,
PNorm(i32),
}
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum TruncateTokens {
#[default]
No,
Yes,
Limit(u32),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
pub enabled: bool,
pub token_cache_size: usize,
pub embedding_cache_size: usize,
pub max_memory_mb: usize,
pub ttl_seconds: u64,
pub enable_metrics: bool,
pub prefix_cache_enabled: bool,
pub prefix_cache_size: usize,
pub min_prefix_length: usize,
pub prefix_frequency_threshold: usize,
pub prefix_ttl_seconds: u64,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: true,
token_cache_size: 10_000,
embedding_cache_size: 10_000,
max_memory_mb: 1024,
ttl_seconds: 3600,
enable_metrics: true,
prefix_cache_enabled: false, prefix_cache_size: 100,
min_prefix_length: 100,
prefix_frequency_threshold: 5,
prefix_ttl_seconds: 7200, }
}
}
impl CacheConfig {
pub fn builder() -> CacheConfigBuilder {
CacheConfigBuilder::new()
}
pub fn validate(&self) -> Result<()> {
if self.token_cache_size == 0 {
return Err(Error::config("Token cache size must be greater than 0"));
}
if self.embedding_cache_size == 0 {
return Err(Error::config("Embedding cache size must be greater than 0"));
}
if self.max_memory_mb == 0 {
return Err(Error::config("Max memory must be greater than 0"));
}
if self.ttl_seconds == 0 {
return Err(Error::config("TTL must be greater than 0"));
}
if self.prefix_cache_enabled {
if self.prefix_cache_size == 0 {
return Err(Error::config("Prefix cache size must be greater than 0"));
}
if self.min_prefix_length < 50 {
return Err(Error::config(
"Minimum prefix length must be at least 50 tokens",
));
}
if self.prefix_frequency_threshold == 0 {
return Err(Error::config(
"Prefix frequency threshold must be greater than 0",
));
}
if self.prefix_ttl_seconds == 0 {
return Err(Error::config("Prefix TTL must be greater than 0"));
}
}
Ok(())
}
}
pub struct CacheConfigBuilder {
config: CacheConfig,
}
impl Default for CacheConfigBuilder {
fn default() -> Self {
Self::new()
}
}
impl CacheConfigBuilder {
pub fn new() -> Self {
Self {
config: CacheConfig::default(),
}
}
#[must_use]
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.config.enabled = enabled;
self
}
#[must_use]
pub fn with_token_cache_size(mut self, size: usize) -> Self {
self.config.token_cache_size = size;
self
}
#[must_use]
pub fn with_embedding_cache_size(mut self, size: usize) -> Self {
self.config.embedding_cache_size = size;
self
}
#[must_use]
pub fn with_max_memory_mb(mut self, mb: usize) -> Self {
self.config.max_memory_mb = mb;
self
}
#[must_use]
pub fn with_ttl_seconds(mut self, seconds: u64) -> Self {
self.config.ttl_seconds = seconds;
self
}
#[must_use]
pub fn with_enable_metrics(mut self, enabled: bool) -> Self {
self.config.enable_metrics = enabled;
self
}
#[must_use]
pub fn with_prefix_cache_enabled(mut self, enabled: bool) -> Self {
self.config.prefix_cache_enabled = enabled;
self
}
#[must_use]
pub fn with_prefix_cache_size(mut self, size: usize) -> Self {
self.config.prefix_cache_size = size;
self
}
#[must_use]
pub fn with_min_prefix_length(mut self, length: usize) -> Self {
self.config.min_prefix_length = length;
self
}
#[must_use]
pub fn with_prefix_frequency_threshold(mut self, threshold: usize) -> Self {
self.config.prefix_frequency_threshold = threshold;
self
}
#[must_use]
pub fn with_prefix_ttl_seconds(mut self, seconds: u64) -> Self {
self.config.prefix_ttl_seconds = seconds;
self
}
pub fn build(self) -> Result<CacheConfig> {
self.config.validate()?;
Ok(self.config)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingConfig {
pub truncate_tokens: TruncateTokens,
}
impl Default for EmbeddingConfig {
fn default() -> Self {
Self {
truncate_tokens: TruncateTokens::No,
}
}
}
impl EmbeddingConfig {
pub fn builder() -> EmbeddingConfigBuilder {
EmbeddingConfigBuilder::new()
}
pub fn validate(&self) -> Result<()> {
if let TruncateTokens::Limit(n) = self.truncate_tokens
&& n == 0
{
return Err(Error::config("Truncation limit must be greater than 0"));
}
Ok(())
}
}
pub struct EmbeddingConfigBuilder {
config: EmbeddingConfig,
}
impl Default for EmbeddingConfigBuilder {
fn default() -> Self {
Self::new()
}
}
impl EmbeddingConfigBuilder {
pub fn new() -> Self {
Self {
config: EmbeddingConfig::default(),
}
}
#[must_use]
pub fn with_truncate_tokens(mut self, truncate: TruncateTokens) -> Self {
self.config.truncate_tokens = truncate;
self
}
#[must_use]
pub fn with_truncate_limit(mut self, limit: u32) -> Self {
self.config.truncate_tokens = TruncateTokens::Limit(limit);
self
}
pub fn build(self) -> Result<EmbeddingConfig> {
self.config.validate()?;
Ok(self.config)
}
}
impl EngineConfig {
pub fn builder() -> EngineConfigBuilder {
EngineConfigBuilder::new()
}
pub fn with_backend_detection() -> EngineConfigBuilder {
let backend = crate::backend::detect_best_backend();
let mut builder = EngineConfigBuilder::new();
if backend.is_gpu_accelerated() {
builder = builder.with_use_gpu(true);
if let Some(gpu_layers) = backend.recommended_gpu_layers() {
builder = builder.with_n_gpu_layers(gpu_layers);
}
}
builder
}
pub fn validate(&self) -> Result<()> {
self.model_config.validate()?;
if let Some(batch_size) = self.batch_size
&& batch_size == 0
{
return Err(Error::config("Batch size must be greater than 0"));
}
if let Some(max_tokens) = self.max_tokens
&& max_tokens == 0
{
return Err(Error::config("Max tokens must be greater than 0"));
}
if let Some(ref cache) = self.cache {
cache.validate()?;
}
if let Some(ref embedding) = self.embedding {
embedding.validate()?;
}
Ok(())
}
pub fn from_env() -> Result<Self> {
let mut builder = EngineConfigBuilder::new();
if let Ok(path) = env::var("EMBELLAMA_MODEL_PATH") {
builder = builder.with_model_path(path);
}
if let Ok(name) = env::var("EMBELLAMA_MODEL_NAME") {
builder = builder.with_model_name(name);
}
if let Ok(size) = env::var("EMBELLAMA_CONTEXT_SIZE") {
let size = size
.parse()
.map_err(|_| Error::config("Invalid EMBELLAMA_CONTEXT_SIZE value"))?;
builder = builder.with_context_size(size);
}
if let Ok(threads) = env::var("EMBELLAMA_N_THREADS") {
let threads = threads
.parse()
.map_err(|_| Error::config("Invalid EMBELLAMA_N_THREADS value"))?;
builder = builder.with_n_threads(threads);
}
if let Ok(use_gpu) = env::var("EMBELLAMA_USE_GPU") {
let use_gpu = use_gpu
.parse()
.map_err(|_| Error::config("Invalid EMBELLAMA_USE_GPU value"))?;
builder = builder.with_use_gpu(use_gpu);
}
builder.build()
}
}
pub struct EngineConfigBuilder {
config: EngineConfig,
}
impl EngineConfigBuilder {
#[must_use]
pub fn new() -> Self {
Self {
config: EngineConfig::default(),
}
}
#[must_use]
pub fn with_model_config(mut self, model_config: ModelConfig) -> Self {
self.config.model_config = model_config;
self
}
#[must_use]
pub fn with_model_path<P: AsRef<Path>>(mut self, path: P) -> Self {
self.config.model_config.model_path = path.as_ref().to_path_buf();
self
}
#[must_use]
pub fn with_model_name<S: Into<String>>(mut self, name: S) -> Self {
self.config.model_config.model_name = name.into();
self
}
#[must_use]
pub fn with_context_size(mut self, size: usize) -> Self {
self.config.model_config.context_size = u32::try_from(size).ok();
self
}
#[must_use]
pub fn with_n_batch(mut self, batch: u32) -> Self {
self.config.model_config.n_batch = Some(batch);
self
}
#[must_use]
pub fn with_n_ubatch(mut self, ubatch: u32) -> Self {
self.config.model_config.n_ubatch = Some(ubatch);
self
}
#[must_use]
pub fn with_n_threads(mut self, threads: usize) -> Self {
self.config.model_config.n_threads = Some(threads);
self
}
#[must_use]
pub fn with_n_gpu_layers(mut self, layers: u32) -> Self {
self.config.model_config.n_gpu_layers = Some(layers);
self
}
#[must_use]
pub fn with_normalization_mode(mut self, mode: NormalizationMode) -> Self {
self.config.model_config.normalization_mode = Some(mode);
self
}
#[must_use]
pub fn with_pooling_strategy(mut self, strategy: PoolingStrategy) -> Self {
self.config.model_config.pooling_strategy = Some(strategy);
self
}
#[must_use]
pub fn with_use_mmap(mut self, use_mmap: bool) -> Self {
self.config.model_config.use_mmap = use_mmap;
self
}
#[must_use]
pub fn with_use_mlock(mut self, use_mlock: bool) -> Self {
self.config.model_config.use_mlock = use_mlock;
self
}
#[must_use]
pub fn with_n_seq_max(mut self, n_seq_max: u32) -> Self {
self.config.model_config.n_seq_max = Some(n_seq_max);
self
}
#[must_use]
pub fn with_use_gpu(mut self, use_gpu: bool) -> Self {
self.config.use_gpu = use_gpu;
self
}
#[must_use]
pub fn with_batch_size(mut self, size: usize) -> Self {
self.config.batch_size = Some(size);
self
}
#[must_use]
pub fn with_max_tokens(mut self, tokens: usize) -> Self {
self.config.max_tokens = Some(tokens);
self
}
#[must_use]
pub fn with_memory_limit_mb(mut self, limit_mb: usize) -> Self {
self.config.memory_limit_mb = Some(limit_mb);
self
}
#[must_use]
pub fn with_verbose(mut self, verbose: bool) -> Self {
self.config.verbose = verbose;
self
}
#[must_use]
pub fn with_seed(mut self, seed: u32) -> Self {
self.config.seed = Some(seed);
self
}
#[must_use]
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.config.temperature = Some(temperature);
self
}
#[must_use]
pub fn with_cache_config(mut self, cache: CacheConfig) -> Self {
self.config.cache = Some(cache);
self
}
#[must_use]
pub fn with_cache_enabled(mut self) -> Self {
self.config.cache = Some(CacheConfig::default());
self
}
#[must_use]
pub fn with_cache_disabled(mut self) -> Self {
self.config.cache = None;
self
}
#[must_use]
pub fn with_embedding_config(mut self, embedding: EmbeddingConfig) -> Self {
self.config.embedding = Some(embedding);
self
}
#[must_use]
pub fn with_truncate_tokens(mut self, truncate: TruncateTokens) -> Self {
let embedding = self
.config
.embedding
.get_or_insert_with(EmbeddingConfig::default);
embedding.truncate_tokens = truncate;
self
}
#[must_use]
pub fn with_truncate_limit(self, limit: u32) -> Self {
self.with_truncate_tokens(TruncateTokens::Limit(limit))
}
pub fn build(self) -> Result<EngineConfig> {
self.config.validate()?;
Ok(self.config)
}
}
impl Default for EngineConfigBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_model_config_builder() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let config = ModelConfig::builder()
.with_model_path(&model_path)
.with_model_name("test-model")
.with_n_ctx(512)
.with_n_threads(4)
.with_n_gpu_layers(0)
.build()
.unwrap();
assert_eq!(config.model_path, model_path);
assert_eq!(config.model_name, "test-model");
assert_eq!(config.n_ctx, Some(512));
assert_eq!(config.n_threads, Some(4));
assert_eq!(config.n_gpu_layers, Some(0));
}
#[test]
fn test_model_config_validation() {
let result = ModelConfig::builder().with_model_name("test").build();
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
Error::ConfigurationError { .. }
));
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let result = ModelConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_n_ctx(0)
.build();
assert!(result.is_err());
}
#[test]
fn test_engine_with_model_config() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let model_config = ModelConfig::builder()
.with_model_path(&model_path)
.with_model_name("test-model")
.with_context_size(1024)
.with_n_threads(8)
.build()
.unwrap();
let engine_config = EngineConfig::builder()
.with_model_config(model_config.clone())
.build()
.unwrap();
assert_eq!(engine_config.model_config.model_path, model_path);
assert_eq!(engine_config.model_config.model_name, "test-model");
assert_eq!(engine_config.model_config.context_size, Some(1024));
assert_eq!(engine_config.model_config.n_threads, Some(8));
}
#[test]
fn test_config_builder() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let config = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test-model")
.with_context_size(512)
.with_n_threads(4)
.with_use_gpu(true)
.build()
.unwrap();
assert_eq!(config.model_config.model_path, model_path);
assert_eq!(config.model_config.model_name, "test-model");
assert_eq!(config.model_config.context_size, Some(512));
assert_eq!(config.model_config.n_threads, Some(4));
assert!(config.use_gpu);
}
#[test]
fn test_config_validation_empty_path() {
let result = EngineConfig::builder().with_model_name("test").build();
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
Error::ConfigurationError { .. }
));
}
#[test]
fn test_config_validation_nonexistent_file() {
let result = EngineConfig::builder()
.with_model_path("/nonexistent/path/model.gguf")
.with_model_name("test")
.build();
assert!(result.is_err());
}
#[test]
fn test_config_validation_empty_name() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let result = EngineConfig::builder().with_model_path(model_path).build();
assert!(result.is_err());
}
#[test]
fn test_config_validation_invalid_values() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let result = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_context_size(0)
.build();
assert!(result.is_err());
let result = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_n_threads(0)
.build();
assert!(result.is_err());
let result = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_batch_size(0)
.build();
assert!(result.is_err());
}
#[test]
fn test_pooling_strategy_default() {
assert_eq!(PoolingStrategy::default(), PoolingStrategy::Mean);
}
#[test]
fn test_engine_config_full_builder() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let config = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("full-test")
.with_context_size(2048)
.with_n_threads(16)
.with_use_gpu(true)
.with_n_gpu_layers(32)
.with_normalization_mode(NormalizationMode::L2)
.with_pooling_strategy(PoolingStrategy::Cls)
.with_batch_size(128)
.build()
.unwrap();
assert_eq!(config.model_config.context_size, Some(2048));
assert_eq!(config.model_config.n_threads, Some(16));
assert!(config.use_gpu);
assert_eq!(config.model_config.n_gpu_layers, Some(32));
assert_eq!(
config.model_config.normalization_mode,
Some(NormalizationMode::L2)
);
assert_eq!(
config.model_config.pooling_strategy,
Some(PoolingStrategy::Cls)
);
assert_eq!(config.batch_size, Some(128));
}
#[test]
fn test_model_config_defaults() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let config = ModelConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.build()
.unwrap();
assert!(config.n_ctx.is_none());
assert!(config.n_threads.is_none());
assert!(config.n_gpu_layers.is_none());
}
#[test]
fn test_engine_config_defaults() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let config = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.build()
.unwrap();
assert!(config.model_config.context_size.is_none());
assert!(config.model_config.n_threads.is_none());
assert!(!config.use_gpu);
assert!(config.model_config.n_gpu_layers.is_none());
assert_eq!(config.model_config.normalization_mode, None);
assert_eq!(config.model_config.pooling_strategy, None);
assert!(config.batch_size.is_none());
}
#[test]
fn test_all_pooling_strategies() {
let strategies = vec![
PoolingStrategy::Mean,
PoolingStrategy::Cls,
PoolingStrategy::Max,
PoolingStrategy::MeanSqrt,
PoolingStrategy::Last,
PoolingStrategy::None,
PoolingStrategy::Rank,
];
for strategy in strategies {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let config = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name(format!("test-{strategy:?}"))
.with_pooling_strategy(strategy)
.build()
.unwrap();
assert_eq!(config.model_config.pooling_strategy, Some(strategy));
}
}
#[test]
fn test_model_config_path_types() {
let dir = tempdir().unwrap();
let model_path_buf = dir.path().join("model1.gguf");
fs::write(&model_path_buf, b"dummy").unwrap();
let config = ModelConfig::builder()
.with_model_path(&model_path_buf)
.with_model_name("test1")
.build()
.unwrap();
assert_eq!(config.model_path, model_path_buf);
let model_path = dir.path().join("model2.gguf");
fs::write(&model_path, b"dummy").unwrap();
let config = ModelConfig::builder()
.with_model_path(model_path.as_path())
.with_model_name("test2")
.build()
.unwrap();
assert_eq!(config.model_path, model_path);
let model_path_str = dir.path().join("model3.gguf");
fs::write(&model_path_str, b"dummy").unwrap();
let config = ModelConfig::builder()
.with_model_path(model_path_str.to_str().unwrap())
.with_model_name("test3")
.build()
.unwrap();
assert_eq!(config.model_path, model_path_str);
}
#[test]
fn test_config_validation_large_values() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let config = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_context_size(1_000_000)
.build()
.unwrap();
assert_eq!(config.model_config.context_size, Some(1_000_000));
let config = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_n_threads(256)
.build()
.unwrap();
assert_eq!(config.model_config.n_threads, Some(256));
let config = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_batch_size(10000)
.build()
.unwrap();
assert_eq!(config.batch_size, Some(10000));
}
#[test]
fn test_config_clone() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let original = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_context_size(512)
.build()
.unwrap();
let cloned = original.clone();
assert_eq!(
cloned.model_config.model_path,
original.model_config.model_path
);
assert_eq!(
cloned.model_config.model_name,
original.model_config.model_name
);
assert_eq!(
cloned.model_config.context_size,
original.model_config.context_size
);
}
#[test]
fn test_model_config_debug_format() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let config = ModelConfig::builder()
.with_model_path(&model_path)
.with_model_name("debug-test")
.build()
.unwrap();
let debug_str = format!("{config:?}");
assert!(debug_str.contains("ModelConfig"));
assert!(debug_str.contains("debug-test"));
}
#[test]
fn test_special_characters_in_name() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let config = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test-model_v2.0")
.build()
.unwrap();
assert_eq!(config.model_config.model_name, "test-model_v2.0");
let config = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("模型-测试")
.build()
.unwrap();
assert_eq!(config.model_config.model_name, "模型-测试");
}
#[test]
fn test_whitespace_in_model_name() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let result = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("")
.build();
assert!(result.is_err());
let result = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name(" ")
.build();
assert!(result.is_err());
}
#[test]
fn test_gpu_config_consistency() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let config = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_use_gpu(false)
.with_n_gpu_layers(10)
.build()
.unwrap();
assert!(!config.use_gpu);
assert_eq!(config.model_config.n_gpu_layers, Some(10));
}
#[test]
fn test_truncate_tokens_default() {
assert_eq!(TruncateTokens::default(), TruncateTokens::No);
}
#[test]
fn test_truncate_tokens_variants() {
assert_eq!(TruncateTokens::No, TruncateTokens::No);
assert_eq!(TruncateTokens::Yes, TruncateTokens::Yes);
assert_eq!(TruncateTokens::Limit(50), TruncateTokens::Limit(50));
assert_ne!(TruncateTokens::No, TruncateTokens::Yes);
assert_ne!(TruncateTokens::Limit(50), TruncateTokens::Limit(100));
}
#[test]
fn test_embedding_config_validation_limit_zero() {
let config = EmbeddingConfig {
truncate_tokens: TruncateTokens::Limit(0),
};
let result = config.validate();
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("must be greater than 0")
);
}
#[test]
fn test_embedding_config_validation_limit_positive() {
let config = EmbeddingConfig {
truncate_tokens: TruncateTokens::Limit(100),
};
assert!(config.validate().is_ok());
}
#[test]
fn test_embedding_config_validation_yes_and_no() {
let config_no = EmbeddingConfig {
truncate_tokens: TruncateTokens::No,
};
assert!(config_no.validate().is_ok());
let config_yes = EmbeddingConfig {
truncate_tokens: TruncateTokens::Yes,
};
assert!(config_yes.validate().is_ok());
}
#[test]
fn test_embedding_config_builder() {
let config = EmbeddingConfig::builder()
.with_truncate_tokens(TruncateTokens::Yes)
.build()
.unwrap();
assert_eq!(config.truncate_tokens, TruncateTokens::Yes);
}
#[test]
fn test_embedding_config_builder_limit() {
let config = EmbeddingConfig::builder()
.with_truncate_limit(500)
.build()
.unwrap();
assert_eq!(config.truncate_tokens, TruncateTokens::Limit(500));
}
#[test]
fn test_embedding_config_builder_validation() {
let result = EmbeddingConfig::builder().with_truncate_limit(100).build();
assert!(result.is_ok());
let result = EmbeddingConfig::builder().with_truncate_limit(0).build();
assert!(result.is_err());
}
#[test]
fn test_engine_config_with_truncate_tokens() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let config = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_truncate_tokens(TruncateTokens::Yes)
.build()
.unwrap();
assert!(config.embedding.is_some());
assert_eq!(
config.embedding.unwrap().truncate_tokens,
TruncateTokens::Yes
);
}
#[test]
fn test_engine_config_with_truncate_limit() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let config = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_truncate_limit(256)
.build()
.unwrap();
assert!(config.embedding.is_some());
assert_eq!(
config.embedding.unwrap().truncate_tokens,
TruncateTokens::Limit(256)
);
}
#[test]
fn test_engine_config_with_embedding_config() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let embedding_config = EmbeddingConfig {
truncate_tokens: TruncateTokens::Limit(512),
};
let config = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_embedding_config(embedding_config.clone())
.build()
.unwrap();
assert!(config.embedding.is_some());
assert_eq!(
config.embedding.unwrap().truncate_tokens,
TruncateTokens::Limit(512)
);
}
#[test]
fn test_engine_config_validation_with_truncation() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let result = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_truncate_limit(100)
.build();
assert!(result.is_ok());
let result = EngineConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.with_truncate_limit(0)
.build();
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn test_symlink_to_non_gguf_file_rejected() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let real_file = dir.path().join("not_a_model.txt");
fs::write(&real_file, b"not a model").unwrap();
let fake_gguf = dir.path().join("sneaky.gguf");
symlink(&real_file, &fake_gguf).unwrap();
let result = ModelConfig::builder()
.with_model_path(&fake_gguf)
.with_model_name("test")
.build();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("non-GGUF"),
"Expected 'non-GGUF' in error, got: {err_msg}"
);
}
#[cfg(unix)]
#[test]
fn test_symlink_to_gguf_file_accepted() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let real_gguf = dir.path().join("real_model.gguf");
fs::write(&real_gguf, b"dummy gguf").unwrap();
let link_gguf = dir.path().join("link_model.gguf");
symlink(&real_gguf, &link_gguf).unwrap();
let result = ModelConfig::builder()
.with_model_path(&link_gguf)
.with_model_name("test")
.build();
assert!(result.is_ok());
}
#[test]
fn test_normal_gguf_path_still_validates() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.gguf");
fs::write(&model_path, b"dummy").unwrap();
let result = ModelConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.build();
assert!(result.is_ok());
}
#[test]
fn test_non_gguf_extension_rejected() {
let dir = tempdir().unwrap();
let model_path = dir.path().join("model.bin");
fs::write(&model_path, b"dummy").unwrap();
let result = ModelConfig::builder()
.with_model_path(&model_path)
.with_model_name("test")
.build();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("non-GGUF"),
"Expected 'non-GGUF' in error, got: {err_msg}"
);
}
}