1use 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#[derive(Clone, Debug, Deserialize, Serialize, Validate)]
29#[serde(transparent)]
30pub struct Alias {
31 #[validate(length(min = 1))]
32 value: String,
33}
34#[derive(Clone, Debug, Deserialize, Serialize)]
36#[serde(from = "String", into = "String")]
37pub enum Argument {
38 Reserved(String),
40 Value(String),
42}
43#[skip_serializing_none]
48#[derive(Clone, Debug, Default, Deserialize, Serialize, Validate)]
49#[serde(rename_all = "camelCase")]
50pub struct Config {
51 #[serde(skip_serializing)]
53 #[validate(length(min = 1))]
54 pub path: Option<String>,
55 pub models_directory: Option<String>,
57 #[validate(length(min = 1))]
59 pub executable: Option<String>,
60 #[validate(range(min = 1))]
62 pub context_size: Option<u64>,
63 #[validate(range(min = 0))]
65 pub ttl: Option<i64>,
66 #[validate(nested)]
68 pub extra_args: Option<Vec<Argument>>,
69 #[validate(nested)]
71 pub environment: Option<Vec<EnvironmentVariable>>,
72 #[validate(nested)]
74 pub models: Option<BTreeMap<String, ModelOverride>>,
75}
76#[derive(Clone, Debug, Deserialize, Serialize, Validate)]
78#[serde(transparent)]
79pub struct EnvironmentVariable {
80 #[validate(custom(function = "is_keyvalue_string"))]
81 value: String,
82}
83#[skip_serializing_none]
85#[derive(Clone, Debug, Default, Deserialize, Serialize, Validate)]
86#[serde(deny_unknown_fields, rename_all = "camelCase")]
87pub struct ModelOverride {
88 #[validate(range(min = 1))]
90 pub context_size: Option<u64>,
91 #[validate(range(min = 0))]
93 pub ttl: Option<i64>,
94 #[validate(nested)]
96 pub aliases: Option<Vec<Alias>>,
97 #[validate(nested)]
99 pub extra_args: Option<Vec<Argument>>,
100 #[validate(nested)]
102 pub environment: Option<Vec<EnvironmentVariable>>,
103 #[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 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 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 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}