Skip to main content

acorn/io/sync/
llama_swap.rs

1//! llama-swap configuration types
2//!
3//! llama-swap is a lightweight HTTP proxy that manages multiple local LLM
4//! models behind a single OpenAI-compatible endpoint. Each model entry is
5//! a command that launches `llama-server` with the appropriate flags.
6//!
7//! See <https://github.com/mostlygeek/llama-swap/blob/main/docs/configuration.md>
8use super::{Options, RenderedOutput, SyncTarget};
9use crate::args;
10use crate::io::{home_directory, read_file, ApiResult};
11use crate::prelude::{OsString, PathBuf};
12use crate::schema::agent::ModelDetails;
13use crate::util::cmd::command_string;
14use crate::util::constants::app::DEFAULT_LLAMA_SWAP_CONFIG_PATH;
15use crate::util::StringConversion;
16use crate::util::ToStrings;
17use alloc::collections::{BTreeMap, BTreeSet};
18use alloc::string::String;
19use alloc::vec::Vec;
20use color_eyre::eyre::eyre;
21use core::{fmt, iter::once};
22use serde::{Deserialize, Serialize};
23use serde_norway::{Mapping, Number, Value};
24use serde_with::skip_serializing_none;
25use validator::{Validate, ValidationError, ValidationErrors};
26
27/// Human-readable alias for a synchronized model
28#[derive(Clone, Debug, Deserialize, Serialize, Validate)]
29#[serde(transparent)]
30pub struct Alias {
31    #[validate(length(min = 1))]
32    value: String,
33}
34/// Additional llama-server command-line argument
35#[derive(Clone, Debug, Deserialize, Serialize)]
36#[serde(from = "String", into = "String")]
37pub enum Argument {
38    /// Argument reserved for ACORN's llama-swap integration
39    Reserved(String),
40    /// User-provided llama-server argument
41    Value(String),
42}
43/// Root llama-swap configuration
44///
45/// The `models` map is keyed by ACORN model name. Each entry's `command`
46/// is generated from the resolved GGUF path and configured defaults.
47#[skip_serializing_none]
48#[derive(Clone, Debug, Default, Deserialize, Serialize, Validate)]
49#[serde(rename_all = "camelCase")]
50pub struct Config {
51    /// Path to the llama-swap configuration file on disk
52    #[serde(skip_serializing)]
53    #[validate(length(min = 1))]
54    pub path: Option<String>,
55    /// Default directory where downloaded model weights live
56    pub models_directory: Option<String>,
57    /// Executable name or path for `llama-server`
58    #[validate(length(min = 1))]
59    pub executable: Option<String>,
60    /// Default context window size for all models
61    #[validate(range(min = 1))]
62    pub context_size: Option<u64>,
63    /// Default time-to-live in seconds; 0 means no expiry
64    #[validate(range(min = 0))]
65    pub ttl: Option<i64>,
66    /// Extra command-line arguments applied to every model command
67    #[validate(nested)]
68    pub extra_args: Option<Vec<Argument>>,
69    /// Extra environment variables as `KEY=VALUE` entries applied to every model
70    #[validate(nested)]
71    pub environment: Option<Vec<EnvironmentVariable>>,
72    /// Per-model overrides keyed by ACORN model name
73    #[validate(nested)]
74    pub models: Option<BTreeMap<String, ModelOverride>>,
75}
76/// Validated llama-swap environment entry serialized as `KEY=VALUE`
77#[derive(Clone, Debug, Deserialize, Serialize, Validate)]
78#[serde(transparent)]
79pub struct EnvironmentVariable {
80    #[validate(custom(function = "is_keyvalue_string"))]
81    value: String,
82}
83/// Per-model override entry keyed by ACORN model name
84#[skip_serializing_none]
85#[derive(Clone, Debug, Default, Deserialize, Serialize, Validate)]
86#[serde(deny_unknown_fields, rename_all = "camelCase")]
87pub struct ModelOverride {
88    /// Model-specific context window size (overrides the global default)
89    #[validate(range(min = 1))]
90    pub context_size: Option<u64>,
91    /// Time-to-live in seconds; 0 means no expiry
92    #[validate(range(min = 0))]
93    pub ttl: Option<i64>,
94    /// Human-readable aliases for selecting this model
95    #[validate(nested)]
96    pub aliases: Option<Vec<Alias>>,
97    /// Additional command-line arguments for `llama-server`
98    #[validate(nested)]
99    pub extra_args: Option<Vec<Argument>>,
100    /// Extra environment variables as `KEY=VALUE` entries
101    #[validate(nested)]
102    pub environment: Option<Vec<EnvironmentVariable>>,
103    /// Executable name or path for this model (overrides global)
104    #[validate(length(min = 1))]
105    pub executable: Option<String>,
106}
107pub(super) struct ModelValidation<'a> {
108    config: &'a Config,
109    model_ids: &'a [String],
110}
111impl SyncTarget for Config {
112    const COMMAND: &'static str = "llama-swap";
113    fn merge(self, overrides: Self) -> Self {
114        Self {
115            path: overrides.path.or(self.path),
116            models_directory: overrides.models_directory.or(self.models_directory),
117            executable: overrides.executable.or(self.executable),
118            context_size: overrides.context_size.or(self.context_size),
119            ttl: overrides.ttl.or(self.ttl),
120            extra_args: overrides.extra_args.or(self.extra_args),
121            environment: overrides.environment.or(self.environment),
122            models: overrides.models.or(self.models),
123        }
124    }
125    fn merge_cli_overrides(self, overrides: Self) -> Self {
126        Self {
127            path: overrides.path.or(self.path),
128            models_directory: overrides.models_directory.or(self.models_directory),
129            ..self
130        }
131    }
132    fn resolve_path(explicit: Option<&str>) -> ApiResult<PathBuf> {
133        explicit
134            .map(|path| PathBuf::from(path.to_string().to_cross_platform_path()))
135            .map_or_else(|| home_directory(DEFAULT_LLAMA_SWAP_CONFIG_PATH), Ok)
136    }
137    fn render(&self, options: Options<'_>) -> ApiResult<RenderedOutput> {
138        Self::resolve_path(self.path.as_deref()).and_then(|path| {
139            path.is_file()
140                .then(|| read_file(path.clone()))
141                .transpose()
142                .map(|content| content.unwrap_or_default())
143                .and_then(|before| {
144                    match before.is_empty() {
145                        | true => Ok(Value::Mapping(Default::default())),
146                        | false => serde_norway::from_str(&before).map_err(|why| eyre!("Failed to parse existing llama-swap config: {why}")),
147                    }
148                    .and_then(|existing| self.upsert(existing, options.models, options.prune))
149                    .and_then(|merged| {
150                        serde_norway::to_string(&merged)
151                            .map_err(|why| eyre!("Failed to serialize llama-swap config: {why}"))
152                            .and_then(|content| {
153                                serde_norway::from_str::<Value>(&content)
154                                    .map(|_| RenderedOutput {
155                                        target: "llama-swap",
156                                        path,
157                                        before,
158                                        content,
159                                    })
160                                    .map_err(|why| eyre!("Generated llama-swap YAML is invalid: {why}"))
161                            })
162                    })
163                })
164        })
165    }
166}
167impl Config {
168    /// Build a shell-safe `llama-server` command string for a given GGUF model path
169    /// ### Note
170    /// The command uses `${PORT}` as a placeholder that llama-swap replaces at runtime
171    pub fn build_command(
172        executable: &str,
173        gguf_path: Option<&str>,
174        extra_args: Option<&[Argument]>,
175        environment: Option<&[EnvironmentVariable]>,
176        context_size: Option<u64>,
177    ) -> String {
178        let line = |command: &str, arguments: Vec<OsString>| command_string(command, &arguments);
179        let extra = extra_args.into_iter().flatten().map(ToString::to_string).collect::<Vec<_>>();
180        let arguments = args![
181            "--offline",
182            "--jinja",
183            ("--batch-size", "2048"),
184            ("--host", "0.0.0.0"),
185            ("--sleep-idle-seconds", "600"),
186            ("--tools", "all"),
187            ("--ubatch-size", "2048")
188        ];
189        let defaults = arguments.iter().enumerate().filter_map(|(index, option)| {
190            let option = option.to_string_lossy();
191            option.starts_with("--").then(|| {
192                let values = index
193                    .checked_add(1)
194                    .and_then(|index| arguments.get(index))
195                    .filter(|value| !value.to_string_lossy().starts_with("--"))
196                    .cloned()
197                    .into_iter()
198                    .collect();
199                line(&option, values)
200            })
201        });
202        let model = gguf_path.map(|path| line("--model", args![path]));
203        let context = context_size.map(|size| line("--ctx-size", args![size.to_string()]));
204        let extra = extra.into_iter().map(OsString::from).collect::<Vec<_>>();
205        let extra = extra
206            .split_first()
207            .map(|(command, arguments)| command_string(&command.to_string_lossy(), arguments));
208        let environment = environment
209            .into_iter()
210            .flatten()
211            .map(|entry| command_string(&format!("env:{entry}"), &[]));
212        once(command_string(executable, &[]))
213            .chain(model)
214            .chain(once(line("--port", args!["${PORT}"])))
215            .chain(context)
216            .chain(defaults)
217            .chain(extra)
218            .chain(environment)
219            .collect::<Vec<_>>()
220            .join("\n  ")
221    }
222    /// Build a llama-swap model entry from resolved model details
223    pub fn model_entry(&self, model: &ModelDetails) -> Value {
224        let model_name = model.name.as_deref().or(model.id.as_deref()).unwrap_or("unknown");
225        let overrides = self.models.as_ref().and_then(|models| models.get(model_name));
226        let aliases = overrides
227            .and_then(|config| config.aliases.as_ref())
228            .map(|aliases| Value::Sequence(aliases.iter().map(|alias| Value::String(alias.as_str().to_string())).collect()));
229        let context_size = overrides.and_then(|config| config.context_size).or(self.context_size);
230        let command = Self::build_command(
231            overrides
232                .and_then(|config| config.executable.as_deref())
233                .or(self.executable.as_deref())
234                .unwrap_or("llama-server"),
235            model.path.as_deref(),
236            overrides.and_then(|config| config.extra_args.as_deref()).or(self.extra_args.as_deref()),
237            overrides.and_then(|config| config.environment.as_deref()).or(self.environment.as_deref()),
238            context_size,
239        );
240        let metadata = Value::Mapping(once((Value::String("acorn".to_string()), Value::Bool(true))).collect());
241        let mapping = once((Value::String("proxy".to_string()), Value::String("http://127.0.0.1:${PORT}".to_string())))
242            .chain(once((Value::String("cmd".to_string()), Value::String(command))))
243            .chain(
244                overrides
245                    .and_then(|config| config.ttl)
246                    .or(self.ttl)
247                    .map(|ttl| (Value::String("ttl".to_string()), Value::Number(Number::from(ttl)))),
248            )
249            .chain(aliases.map(|value| (Value::String("aliases".to_string()), value)))
250            .chain(once((Value::String("metadata".to_string()), metadata)))
251            .collect();
252        Value::Mapping(mapping)
253    }
254    /// Merge synchronized models into an existing llama-swap document
255    pub fn upsert(&self, existing: Value, models: &[ModelDetails], prune: bool) -> ApiResult<Value> {
256        match existing {
257            | Value::Null => self.upsert(Value::Mapping(Default::default()), models, prune),
258            | Value::Mapping(root) => {
259                let mut root = merge_mappings(Self::base_config(), root);
260                let models_key = Value::String("models".to_string());
261                root.remove(Value::String("modelsDir".to_string()));
262                let existing_models = root
263                    .remove(&models_key)
264                    .and_then(|value| match value {
265                        | Value::Mapping(models) => Some(models),
266                        | _ => None,
267                    })
268                    .unwrap_or_default();
269                let current_ids = models
270                    .iter()
271                    .filter_map(|model| model.name.as_ref().or(model.id.as_ref()))
272                    .cloned()
273                    .collect::<BTreeSet<_>>();
274                let retained = existing_models
275                    .into_iter()
276                    .filter(|(identifier, model)| {
277                        !prune || identifier.as_str().is_some_and(|identifier| current_ids.contains(identifier)) || !is_managed(model)
278                    })
279                    .collect::<Mapping>();
280                let merged = models.iter().fold(retained, |mut entries, model| {
281                    let identifier = model
282                        .name
283                        .as_ref()
284                        .or(model.id.as_ref())
285                        .cloned()
286                        .unwrap_or_else(|| "unknown".to_string());
287                    let key = Value::String(identifier);
288                    let existing = entries.remove(&key).unwrap_or_else(|| Value::Mapping(Default::default()));
289                    entries.insert(key, merge_model(existing, self.model_entry(model)));
290                    entries
291                });
292                root.insert(models_key, Value::Mapping(merged));
293                Ok(Value::Mapping(root))
294            }
295            | _ => Err(eyre!("Existing llama-swap configuration root must be a mapping")),
296        }
297    }
298    fn base_config() -> Mapping {
299        let proxy = once((Value::String("listen".to_string()), Value::String("http://0.0.0.0:10732".to_string()))).collect();
300        let macros = [
301            ("context_size", "${env.LLAMA_ARG_CTX_SIZE}"),
302            ("parallel", "${env.LLAMA_ARG_N_PARALLEL}"),
303            ("models_dir", "${env.HOME}/.models"),
304            ("ssl_key", "${env.HOME}/certs/my.key"),
305            ("ssl_cert", "${env.HOME}/certs/my.pem"),
306        ]
307        .into_iter()
308        .map(|(key, value)| (Value::String(key.to_string()), Value::String(value.to_string())))
309        .collect();
310        [
311            (Value::String("healthCheckTimeout".to_string()), Value::Number(Number::from(500))),
312            (Value::String("proxy".to_string()), Value::Mapping(proxy)),
313            (Value::String("macros".to_string()), Value::Mapping(macros)),
314        ]
315        .into_iter()
316        .collect()
317    }
318}
319impl Alias {
320    fn as_str(&self) -> &str {
321        &self.value
322    }
323}
324impl From<&str> for Alias {
325    fn from(value: &str) -> Self {
326        Self { value: value.to_string() }
327    }
328}
329impl fmt::Display for Argument {
330    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
331        match self {
332            | Self::Reserved(value) | Self::Value(value) => value.fmt(formatter),
333        }
334    }
335}
336impl From<String> for Argument {
337    fn from(value: String) -> Self {
338        match value.split_once('=').map_or(value.as_str(), |(option, _)| option) {
339            | "--port" | "--model" | "-m" => Self::Reserved(value),
340            | _ => Self::Value(value),
341        }
342    }
343}
344impl From<&str> for Argument {
345    fn from(value: &str) -> Self {
346        Self::from(value.to_string())
347    }
348}
349impl From<Argument> for String {
350    fn from(value: Argument) -> Self {
351        value.to_string()
352    }
353}
354impl<'a> From<(&'a Config, &'a [String])> for ModelValidation<'a> {
355    fn from((config, model_ids): (&'a Config, &'a [String])) -> Self {
356        Self { config, model_ids }
357    }
358}
359impl Validate for Argument {
360    fn validate(&self) -> Result<(), ValidationErrors> {
361        match self {
362            | Self::Reserved(_) => {
363                let mut errors = ValidationErrors::new();
364                errors.add(
365                    "argument",
366                    ValidationError::new("reserved").with_message("Cannot contain --port, --model, or -m".into()),
367                );
368                Err(errors)
369            }
370            | Self::Value(_) => Ok(()),
371        }
372    }
373}
374impl Validate for ModelValidation<'_> {
375    fn validate(&self) -> Result<(), ValidationErrors> {
376        self.config
377            .models
378            .as_ref()
379            .into_iter()
380            .flat_map(|models| models.iter())
381            .try_fold(BTreeSet::new(), |mut aliases, (model_id, config)| {
382                match self.model_ids.iter().any(|candidate| candidate == model_id) {
383                    | false => Err(validation_errors(
384                        "unknown_model",
385                        format!("llamaSwap.models contains unknown model override '{model_id}'"),
386                    )),
387                    | true => config
388                        .aliases
389                        .as_ref()
390                        .into_iter()
391                        .flatten()
392                        .try_for_each(|alias| {
393                            let alias = alias.as_str();
394                            match (
395                                self.model_ids.iter().any(|candidate| candidate == alias),
396                                aliases.insert(alias.to_string()),
397                            ) {
398                                | (true, _) => Err(validation_errors(
399                                    "model_alias",
400                                    format!("llamaSwap alias '{alias}' conflicts with a configured model ID"),
401                                )),
402                                | (_, false) => Err(validation_errors("duplicate_alias", format!("Duplicate llamaSwap alias '{alias}'"))),
403                                | _ => Ok(()),
404                            }
405                        })
406                        .map(|()| aliases),
407                }
408            })
409            .map(|_| ())
410    }
411}
412impl fmt::Display for EnvironmentVariable {
413    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
414        self.value.fmt(formatter)
415    }
416}
417impl From<&str> for EnvironmentVariable {
418    fn from(value: &str) -> Self {
419        Self { value: value.to_string() }
420    }
421}
422fn is_keyvalue_string(value: &str) -> Result<(), ValidationError> {
423    let is_valid = value
424        .split_once('=')
425        .is_some_and(|(key, _)| !key.trim().is_empty() && !key.chars().any(char::is_whitespace));
426    match is_valid {
427        | true => Ok(()),
428        | false => Err(ValidationError::new("keyvalue").with_message("Provide a valid KEY=VALUE entry".into())),
429    }
430}
431fn is_managed(model: &Value) -> bool {
432    model
433        .as_mapping()
434        .and_then(|model| model.get(Value::String("metadata".to_string())))
435        .and_then(Value::as_mapping)
436        .and_then(|metadata| metadata.get(Value::String("acorn".to_string())))
437        .and_then(Value::as_bool)
438        .unwrap_or(false)
439}
440fn merge_mappings(defaults: Mapping, overrides: Mapping) -> Mapping {
441    defaults.into_iter().fold(overrides, |mut merged, (key, default_value)| {
442        let value = match (merged.get(&key), default_value) {
443            | (Some(Value::Mapping(overrides)), Value::Mapping(defaults)) => Some(Value::Mapping(merge_mappings(defaults, overrides.clone()))),
444            | (None, value) => Some(value),
445            | _ => None,
446        };
447        if let Some(value) = value {
448            merged.insert(key, value);
449        }
450        merged
451    })
452}
453fn merge_model(existing: Value, generated: Value) -> Value {
454    let owned = vec!["cmd", "command", "ttl", "aliases"]
455        .to_strings()
456        .into_iter()
457        .map(Value::String)
458        .collect::<Vec<_>>();
459    let mut existing = match existing {
460        | Value::Mapping(mapping) => mapping,
461        | _ => Default::default(),
462    };
463    let mut generated = match generated {
464        | Value::Mapping(mapping) => mapping,
465        | _ => Default::default(),
466    };
467    let proxy_key = Value::String("proxy".to_string());
468    if existing.contains_key(&proxy_key) {
469        generated.remove(&proxy_key);
470    }
471    let metadata_key = Value::String("metadata".to_string());
472    let metadata = existing
473        .remove(&metadata_key)
474        .and_then(|value| match value {
475            | Value::Mapping(mapping) => Some(mapping),
476            | _ => None,
477        })
478        .unwrap_or_default()
479        .into_iter()
480        .chain(
481            generated
482                .remove(&metadata_key)
483                .and_then(|value| match value {
484                    | Value::Mapping(mapping) => Some(mapping),
485                    | _ => None,
486                })
487                .unwrap_or_default(),
488        )
489        .collect();
490    Value::Mapping(
491        existing
492            .into_iter()
493            .filter(|(key, _)| !owned.contains(key))
494            .chain(generated)
495            .chain(once((metadata_key, Value::Mapping(metadata))))
496            .collect(),
497    )
498}
499fn validation_errors(code: &'static str, message: String) -> ValidationErrors {
500    let mut errors = ValidationErrors::new();
501    errors.add("models", ValidationError::new(code).with_message(message.into()));
502    errors
503}
504
505#[cfg(test)]
506mod tests {
507    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
508    use super::*;
509
510    #[test]
511    fn test_argument_validation_rejects_reserved_options() {
512        ["--port", "--port=9000", "--model", "--model=/tmp/model.gguf", "-m=/tmp/model.gguf"]
513            .into_iter()
514            .for_each(|argument| assert!(Argument::from(argument).validate().is_err()));
515        assert!(Argument::from("--flash-attn").validate().is_ok());
516    }
517    #[test]
518    fn test_build_command_simple() {
519        let cmd = Config::build_command("llama-server", Some("/models/qwen.gguf"), None, None, Some(8192));
520        assert_eq!(
521            cmd,
522            "llama-server\n  --model /models/qwen.gguf\n  --port ${PORT}\n  --ctx-size 8192\n  --offline\n  --jinja\n  --batch-size 2048\n  --host 0.0.0.0\n  --sleep-idle-seconds 600\n  --tools all\n  --ubatch-size 2048"
523        );
524    }
525    #[test]
526    fn test_build_command_with_environment() {
527        let env = vec![EnvironmentVariable::from("CUDA_VISIBLE_DEVICES=0")];
528        let cmd = Config::build_command("llama-server", Some("/models/qwen.gguf"), None, Some(&env), None);
529        assert!(cmd.contains("env:CUDA_VISIBLE_DEVICES=0"));
530    }
531    #[test]
532    fn test_build_command_with_extra_args() {
533        let extras = vec![
534            Argument::from("--batch-size"),
535            Argument::from("1024"),
536            Argument::from("--flash-attn"),
537            Argument::from("on"),
538        ];
539        let cmd = Config::build_command("llama-server", Some("/models/qwen.gguf"), Some(&extras), None, None);
540        assert!(cmd.contains("--batch-size 1024"));
541        assert!(cmd
542            .find("--batch-size 2048")
543            .zip(cmd.rfind("--batch-size 1024"))
544            .is_some_and(|(default, override_)| default < override_));
545        assert!(cmd.contains("--flash-attn"));
546        assert!(cmd.contains("on"));
547    }
548    #[test]
549    fn test_build_command_with_optional_values() {
550        let cmd = Config::build_command("llama-server", None, None, None, None);
551        assert!(cmd.starts_with("llama-server\n  --port ${PORT}\n  --offline"));
552        assert!(cmd.ends_with("--ubatch-size 2048"));
553    }
554    #[test]
555    fn test_build_command_with_spaces_in_path() {
556        let cmd = Config::build_command("llama-server", Some("/path with spaces/model.gguf"), None, None, None);
557        assert!(cmd.contains('"'));
558    }
559    #[test]
560    fn test_resolve_path_uses_llama_swap_user_config() {
561        assert_eq!(
562            Config::resolve_path(None).unwrap(),
563            home_directory(DEFAULT_LLAMA_SWAP_CONFIG_PATH).unwrap()
564        );
565    }
566    #[test]
567    fn test_upsert_preserves_unrelated_values_and_prunes_only_managed_models() {
568        let existing: Value = serde_norway::from_str(
569            r#"modelsDir: /legacy/models
570healthCheckTimeout: 120
571models:
572  qwen:
573    cmd: old
574    proxy: http://127.0.0.1:9000
575    metadata:
576      owner: user
577  stale:
578    cmd: stale
579    metadata:
580      acorn: true
581  custom:
582    cmd: custom
583"#,
584        )
585        .unwrap();
586        let models = [ModelDetails::init().id("qwen").name("qwen").path("/models/qwen.gguf").build()];
587        let config = Config::default();
588        let additive = config.upsert(existing.clone(), &models, false).unwrap();
589        let additive_text = serde_norway::to_string(&additive).unwrap();
590        assert!(additive_text.contains("healthCheckTimeout: 120"));
591        assert!(additive_text.contains("proxy: http://127.0.0.1:9000"));
592        assert!(additive_text.contains("owner: user"));
593        assert!(additive_text.contains("acorn: true"));
594        assert!(additive_text.contains("stale:"));
595        assert!(additive_text.contains("custom:"));
596        assert!(additive_text.contains("cmd: |-"));
597        assert!(additive_text.contains("llama-server\n"));
598        assert!(additive_text.contains("--port ${PORT}\n"));
599        assert!(additive_text.contains("--model /models/qwen.gguf\n"));
600        assert!(!additive_text.contains("modelsDir"));
601        let pruned = config.upsert(existing, &models, true).unwrap();
602        let pruned_text = serde_norway::to_string(&pruned).unwrap();
603        assert!(!pruned_text.contains("stale:"));
604        assert!(pruned_text.contains("custom:"));
605        let repeated = config.upsert(pruned, &models, true).unwrap();
606        assert_eq!(pruned_text, serde_norway::to_string(&repeated).unwrap());
607    }
608    #[test]
609    fn test_upsert_adds_gold_base_defaults() {
610        let models = [ModelDetails::init().id("qwen").name("qwen").path("/models/qwen.gguf").build()];
611        let output = Config::default()
612            .upsert(Value::Mapping(Default::default()), &models, false)
613            .and_then(|value| serde_norway::to_string(&value).map_err(Into::into))
614            .unwrap();
615        assert!(output.contains("healthCheckTimeout: 500"));
616        assert!(output.contains("listen: http://0.0.0.0:10732"));
617        assert!(output.contains("context_size: ${env.LLAMA_ARG_CTX_SIZE}"));
618        assert!(output.contains("parallel: ${env.LLAMA_ARG_N_PARALLEL}"));
619        assert!(output.contains("models_dir: ${env.HOME}/.models"));
620        assert!(output.contains("ssl_key: ${env.HOME}/certs/my.key"));
621        assert!(output.contains("ssl_cert: ${env.HOME}/certs/my.pem"));
622        assert!(output.contains("proxy: http://127.0.0.1:${PORT}"));
623        assert!(output.contains("--offline\n"));
624        assert!(output.contains("--tools all\n"));
625    }
626    #[test]
627    fn test_validate_rejects_invalid_context_ttl_environment_and_aliases() {
628        let model_ids = vec!["qwen".to_string(), "gemma".to_string()];
629        let invalid = [
630            Config {
631                context_size: Some(0),
632                ..Default::default()
633            },
634            Config {
635                ttl: Some(-1),
636                ..Default::default()
637            },
638            Config {
639                environment: Some(vec![EnvironmentVariable::from("INVALID")]),
640                ..Default::default()
641            },
642            Config {
643                models: Some(
644                    [
645                        (
646                            "qwen".to_string(),
647                            ModelOverride {
648                                aliases: Some(vec![Alias::from("shared")]),
649                                ..Default::default()
650                            },
651                        ),
652                        (
653                            "gemma".to_string(),
654                            ModelOverride {
655                                aliases: Some(vec![Alias::from("shared")]),
656                                ..Default::default()
657                            },
658                        ),
659                    ]
660                    .into_iter()
661                    .collect(),
662                ),
663                ..Default::default()
664            },
665        ];
666        invalid.iter().for_each(|config| {
667            assert!(Validate::validate(config).is_err() || ModelValidation::from((config, model_ids.as_slice())).validate().is_err());
668        });
669    }
670}