1use crate::{
2 ConfigAliasMode, ConfigApplyMode, ConfigConditionalDisable, ConfigConflictRule,
3 ConfigConstraint, ConfigControlAvailability, ConfigControlAvailabilitySource,
4 ConfigControlBehavior, ConfigControlCondition, ConfigControlSurface, ConfigDisabledWritePolicy,
5 ConfigNumericControl, ConfigOptionsSource, ConfigPath, ConfigPathAlias,
6 ConfigPresentationMetadata, ConfigRestartScope, ConfigSchema, ConfigSettingOwner,
7 ConfigSettingSchema, ConfigSupportState, ConfigTextFormat, ConfigValueSchema, ConfigVisibility,
8 GpuAssignment, HardwareConfig, MeshConfig, ModelConfigDefaults, ModelConfigEntry,
9 ModelFitConfig, MultimodalConfig, PluginConfigEntry, RequestDefaultsConfig, ThroughputConfig,
10};
11use anyhow::{Result, bail};
12use mesh_llm_types::runtime::ModelRuntimeKind;
13use std::net::SocketAddr;
14
15#[derive(Clone, Debug, Default)]
16pub struct LocalServingNodeConfig {
17 pub model: String,
18 pub runtime: Option<ModelRuntimeKind>,
19 pub device: Option<String>,
20 pub context_size: Option<u32>,
21 pub parallel: Option<usize>,
22 pub mmproj: Option<String>,
23 pub owner_control_bind: Option<SocketAddr>,
24 pub owner_control_advertise_addr: Option<SocketAddr>,
25 pub gpu_assignment: Option<GpuAssignment>,
26}
27
28#[derive(Clone, Debug, Default)]
29pub struct ConfigSchemaBuilder {
30 settings: Vec<ConfigSettingSchema>,
31}
32
33impl ConfigSchemaBuilder {
34 pub fn new() -> Self {
35 Self::default()
36 }
37
38 pub fn setting(&mut self, setting: ConfigSettingSchema) -> &mut Self {
39 self.settings.push(setting);
40 self
41 }
42
43 pub fn build(self) -> ConfigSchema {
44 ConfigSchema {
45 settings: self.settings,
46 }
47 }
48}
49
50pub fn built_in_config_schema() -> ConfigSchema {
51 ConfigSchema {
52 settings: crate::built_in_config_settings(),
53 }
54}
55
56#[derive(Clone, Debug)]
57pub struct ConfigSettingSchemaBuilder {
58 setting: ConfigSettingSchema,
59}
60
61impl ConfigSettingSchemaBuilder {
62 pub fn new(path: ConfigPath, value_schema: ConfigValueSchema) -> Self {
63 Self {
64 setting: ConfigSettingSchema {
65 path,
66 alias_policy: Default::default(),
67 owner: ConfigSettingOwner::BuiltIn,
68 value_schema,
69 support: ConfigSupportState::Supported,
70 control_surfaces: Vec::new(),
71 apply_mode: ConfigApplyMode::StaticOnLoad,
72 restart_scope: ConfigRestartScope::None,
73 visibility: ConfigVisibility::User,
74 constraints: Vec::new(),
75 description: None,
76 presentation: None,
77 control_behavior: None,
78 },
79 }
80 }
81
82 pub fn owner(&mut self, owner: ConfigSettingOwner) -> &mut Self {
83 self.setting.owner = owner;
84 self
85 }
86
87 pub fn support(&mut self, support: ConfigSupportState) -> &mut Self {
88 self.setting.support = support;
89 self
90 }
91
92 pub fn control_surface(&mut self, surface: ConfigControlSurface) -> &mut Self {
93 self.setting.control_surfaces.push(surface);
94 self
95 }
96
97 pub fn apply_mode(&mut self, apply_mode: ConfigApplyMode) -> &mut Self {
98 self.setting.apply_mode = apply_mode;
99 self
100 }
101
102 pub fn restart_scope(&mut self, restart_scope: ConfigRestartScope) -> &mut Self {
103 self.setting.restart_scope = restart_scope;
104 self
105 }
106
107 pub fn visibility(&mut self, visibility: ConfigVisibility) -> &mut Self {
108 self.setting.visibility = visibility;
109 self
110 }
111
112 pub fn description(&mut self, description: impl Into<String>) -> &mut Self {
113 self.setting.description = Some(description.into());
114 self
115 }
116
117 pub fn presentation(&mut self, presentation: ConfigPresentationMetadata) -> &mut Self {
118 self.setting.presentation = Some(presentation);
119 self
120 }
121
122 pub fn control_behavior(&mut self, control_behavior: ConfigControlBehavior) -> &mut Self {
123 self.setting.control_behavior = Some(control_behavior);
124 self
125 }
126
127 pub fn control_numeric(&mut self, numeric: ConfigNumericControl) -> &mut Self {
128 self.control_behavior_mut().numeric = Some(numeric);
129 self
130 }
131
132 pub fn control_numeric_min(&mut self, min: f64) -> &mut Self {
133 self.control_numeric_mut().min = Some(min);
134 self
135 }
136
137 pub fn control_numeric_max(&mut self, max: f64) -> &mut Self {
138 self.control_numeric_mut().max = Some(max);
139 self
140 }
141
142 pub fn control_numeric_step(&mut self, step: f64) -> &mut Self {
143 self.control_numeric_mut().step = Some(step);
144 self
145 }
146
147 pub fn control_numeric_soft_min(&mut self, soft_min: f64) -> &mut Self {
148 self.control_numeric_mut().soft_min = Some(soft_min);
149 self
150 }
151
152 pub fn control_numeric_soft_max(&mut self, soft_max: f64) -> &mut Self {
153 self.control_numeric_mut().soft_max = Some(soft_max);
154 self
155 }
156
157 pub fn control_numeric_unit(&mut self, unit: impl Into<String>) -> &mut Self {
158 self.control_numeric_mut().unit = Some(unit.into());
159 self
160 }
161
162 pub fn control_text_format(&mut self, text_format: ConfigTextFormat) -> &mut Self {
163 self.control_behavior_mut().text_format = Some(text_format);
164 self
165 }
166
167 pub fn control_options_source(&mut self, options_source: ConfigOptionsSource) -> &mut Self {
168 self.control_behavior_mut().options_source = Some(options_source);
169 self
170 }
171
172 pub fn control_options_static(&mut self) -> &mut Self {
173 self.control_options_source(ConfigOptionsSource::Static)
174 }
175
176 pub fn control_options_runtime_gpus(&mut self) -> &mut Self {
177 self.control_options_source(ConfigOptionsSource::RuntimeGpus)
178 }
179
180 pub fn control_availability(&mut self, availability: ConfigControlAvailability) -> &mut Self {
181 self.control_behavior_mut().availability = Some(availability);
182 self
183 }
184
185 pub fn control_availability_enabled(&mut self, enabled: bool) -> &mut Self {
186 self.control_availability_mut().enabled = enabled;
187 self
188 }
189
190 pub fn control_availability_source(
191 &mut self,
192 source: ConfigControlAvailabilitySource,
193 ) -> &mut Self {
194 self.control_availability_mut().source = source;
195 self
196 }
197
198 pub fn control_availability_reason(&mut self, reason: impl Into<String>) -> &mut Self {
199 self.control_availability_mut().reason = Some(reason.into());
200 self
201 }
202
203 pub fn control_availability_note(&mut self, note: impl Into<String>) -> &mut Self {
204 self.control_availability_mut().note = Some(note.into());
205 self
206 }
207
208 pub fn control_enable_when(&mut self, condition: ConfigControlCondition) -> &mut Self {
209 self.control_behavior_mut().enable_when.push(condition);
210 self
211 }
212
213 pub fn control_disable_when(&mut self, disable: ConfigConditionalDisable) -> &mut Self {
214 self.control_behavior_mut().disable_when.push(disable);
215 self
216 }
217
218 pub fn control_conflict(&mut self, conflict: ConfigConflictRule) -> &mut Self {
219 self.control_behavior_mut().conflicts.push(conflict);
220 self
221 }
222
223 pub fn control_write_policy(&mut self, policy: ConfigDisabledWritePolicy) -> &mut Self {
224 self.control_behavior_mut().write_policy = Some(policy);
225 self
226 }
227
228 pub fn presentation_label(&mut self, label: impl Into<String>) -> &mut Self {
229 self.presentation_mut().label = Some(label.into());
230 self
231 }
232
233 pub fn presentation_help(&mut self, help: impl Into<String>) -> &mut Self {
234 self.presentation_mut().help = Some(help.into());
235 self
236 }
237
238 pub fn presentation_category(
239 &mut self,
240 id: impl Into<String>,
241 label: impl Into<String>,
242 summary: impl Into<String>,
243 order: u32,
244 ) -> &mut Self {
245 let presentation = self.presentation_mut();
246 presentation.category_id = Some(id.into());
247 presentation.category_label = Some(label.into());
248 presentation.category_summary = Some(summary.into());
249 presentation.category_order = Some(order);
250 self
251 }
252
253 pub fn presentation_order(&mut self, order: u32) -> &mut Self {
254 self.presentation_mut().setting_order = Some(order);
255 self
256 }
257
258 pub fn presentation_unit(&mut self, unit: impl Into<String>) -> &mut Self {
259 self.presentation_mut().unit = Some(unit.into());
260 self
261 }
262
263 pub fn presentation_placeholder(&mut self, placeholder: impl Into<String>) -> &mut Self {
264 self.presentation_mut().placeholder = Some(placeholder.into());
265 self
266 }
267
268 pub fn presentation_control_hint(&mut self, control_hint: impl Into<String>) -> &mut Self {
269 self.presentation_mut().control_hint = Some(control_hint.into());
270 self
271 }
272
273 pub fn presentation_renderer_id(&mut self, renderer_id: impl Into<String>) -> &mut Self {
274 self.presentation_mut().renderer_id = Some(renderer_id.into());
275 self
276 }
277
278 pub fn alias(&mut self, alias: ConfigPathAlias) -> &mut Self {
279 self.setting.alias_policy.mode = ConfigAliasMode::CanonicalWithLegacyAliases;
280 self.setting.alias_policy.aliases.push(alias);
281 self
282 }
283
284 pub fn constraint(&mut self, constraint: ConfigConstraint) -> &mut Self {
285 self.setting.constraints.push(constraint);
286 self
287 }
288
289 pub fn build(self) -> ConfigSettingSchema {
290 self.setting
291 }
292
293 fn presentation_mut(&mut self) -> &mut ConfigPresentationMetadata {
294 self.setting
295 .presentation
296 .get_or_insert_with(ConfigPresentationMetadata::default)
297 }
298
299 fn control_behavior_mut(&mut self) -> &mut ConfigControlBehavior {
300 self.setting
301 .control_behavior
302 .get_or_insert_with(ConfigControlBehavior::default)
303 }
304
305 fn control_numeric_mut(&mut self) -> &mut ConfigNumericControl {
306 self.control_behavior_mut()
307 .numeric
308 .get_or_insert_with(ConfigNumericControl::default)
309 }
310
311 fn control_availability_mut(&mut self) -> &mut ConfigControlAvailability {
312 self.control_behavior_mut()
313 .availability
314 .get_or_insert(ConfigControlAvailability {
315 enabled: true,
316 reason: None,
317 note: None,
318 source: ConfigControlAvailabilitySource::Static,
319 })
320 }
321}
322
323#[derive(Clone, Debug)]
324pub struct ConfigEditor {
325 config: MeshConfig,
326}
327
328impl ConfigEditor {
329 pub fn new(config: MeshConfig) -> Self {
330 Self { config }
331 }
332
333 pub fn into_config(self) -> MeshConfig {
334 self.config
335 }
336
337 pub fn config(&self) -> &MeshConfig {
338 &self.config
339 }
340
341 pub fn set_version(&mut self, version: Option<u32>) -> &mut Self {
342 self.config.version = version;
343 self
344 }
345
346 pub fn set_gpu_assignment(&mut self, assignment: GpuAssignment) -> &mut Self {
347 self.config.gpu.assignment = assignment;
348 self
349 }
350
351 pub fn set_gpu_parallel(&mut self, parallel: Option<usize>) -> &mut Self {
352 self.config.gpu.parallel = parallel;
353 self
354 }
355
356 pub fn set_owner_control_bind(&mut self, bind: Option<SocketAddr>) -> &mut Self {
357 self.config.owner_control.bind = bind;
358 self
359 }
360
361 pub fn set_owner_control_advertise_addr(
362 &mut self,
363 advertise_addr: Option<SocketAddr>,
364 ) -> &mut Self {
365 self.config.owner_control.advertise_addr = advertise_addr;
366 self
367 }
368
369 pub fn defaults(&mut self) -> ModelDefaultsEditor<'_> {
370 ModelDefaultsEditor {
371 defaults: self.config.defaults.get_or_insert_with(Default::default),
372 }
373 }
374
375 pub fn set_default_runtime(&mut self, runtime: ModelRuntimeKind) -> &mut Self {
376 self.defaults().runtime(runtime);
377 self
378 }
379
380 pub fn clear_default_runtime(&mut self) -> &mut Self {
381 self.defaults().clear_runtime();
382 self
383 }
384
385 pub fn set_default_device(&mut self, device: impl Into<String>) -> &mut Self {
386 self.defaults().device(device);
387 self
388 }
389
390 pub fn clear_default_device(&mut self) -> &mut Self {
391 self.defaults().clear_device();
392 self
393 }
394
395 pub fn set_default_context_size(&mut self, context_size: Option<u32>) -> &mut Self {
396 self.defaults().context_size(context_size);
397 self
398 }
399
400 pub fn configure_local_serving_node(
401 &mut self,
402 node: LocalServingNodeConfig,
403 ) -> Result<&mut Self> {
404 self.set_version(Some(1));
405 if let Some(assignment) = node.gpu_assignment {
406 self.set_gpu_assignment(assignment);
407 }
408 if node.owner_control_bind.is_some() {
409 self.set_owner_control_bind(node.owner_control_bind);
410 }
411 if node.owner_control_advertise_addr.is_some() {
412 self.set_owner_control_advertise_addr(node.owner_control_advertise_addr);
413 }
414 let mut model = self.upsert_model(node.model, String::new())?;
415 if let Some(runtime) = node.runtime {
416 model.runtime(runtime);
417 }
418 if let Some(device) = node.device {
419 model.device(device);
420 }
421 if let Some(context_size) = node.context_size {
422 model.context_size(context_size);
423 }
424 if let Some(parallel) = node.parallel {
425 model.parallel(parallel);
426 }
427 if let Some(mmproj) = node.mmproj {
428 model.mmproj(mmproj);
429 }
430 Ok(self)
431 }
432
433 pub fn upsert_model(
434 &mut self,
435 model_ref: impl AsRef<str>,
436 derived_profile: String,
437 ) -> Result<ModelConfigEditor<'_>> {
438 let model_ref = normalize_non_empty(model_ref.as_ref(), "model ref")?;
439 let index = match self.config.models.iter().position(|entry| {
440 entry.model == model_ref && entry.derived_profile() == derived_profile
441 }) {
442 Some(index) => index,
443 None => {
444 self.config.models.push(ModelConfigEntry {
445 model: model_ref,
446 ..ModelConfigEntry::default()
447 });
448 self.config.models.len() - 1
449 }
450 };
451 Ok(ModelConfigEditor {
452 model: &mut self.config.models[index],
453 })
454 }
455
456 pub fn remove_model(
457 &mut self,
458 model_ref: impl AsRef<str>,
459 derived_profile: String,
460 ) -> Result<&mut Self> {
461 let model_ref = normalize_non_empty(model_ref.as_ref(), "model ref")?;
462 self.config.models.retain(|entry| {
463 !(entry.model == model_ref && entry.derived_profile() == derived_profile)
464 });
465 Ok(self)
466 }
467
468 pub fn model_refs(&self) -> Vec<String> {
469 self.config
470 .models
471 .iter()
472 .map(|entry| entry.model.clone())
473 .collect()
474 }
475
476 pub fn upsert_plugin(&mut self, name: impl AsRef<str>) -> Result<PluginConfigEditor<'_>> {
477 let name = normalize_non_empty(name.as_ref(), "plugin name")?;
478 let index = match self
479 .config
480 .plugins
481 .iter()
482 .position(|entry| entry.name == name)
483 {
484 Some(index) => index,
485 None => {
486 self.config.plugins.push(PluginConfigEntry {
487 name,
488 enabled: None,
489 web_ui_enabled: None,
490 command: None,
491 args: Vec::new(),
492 url: None,
493 settings: Default::default(),
494 startup: Default::default(),
495 });
496 self.config.plugins.len() - 1
497 }
498 };
499 Ok(PluginConfigEditor {
500 plugin: &mut self.config.plugins[index],
501 })
502 }
503
504 pub fn enable_builtin_plugin(&mut self, name: impl AsRef<str>) -> Result<&mut Self> {
505 self.upsert_plugin(name)?.enabled(true);
506 Ok(self)
507 }
508
509 pub fn disable_plugin(&mut self, name: impl AsRef<str>) -> Result<&mut Self> {
510 self.upsert_plugin(name)?.enabled(false);
511 Ok(self)
512 }
513
514 pub fn upsert_external_plugin(
515 &mut self,
516 name: impl AsRef<str>,
517 command: impl Into<String>,
518 args: impl IntoIterator<Item = impl Into<String>>,
519 ) -> Result<&mut Self> {
520 self.upsert_plugin(name)?
521 .enabled(true)
522 .command(command)
523 .args(args);
524 Ok(self)
525 }
526}
527
528impl From<MeshConfig> for ConfigEditor {
529 fn from(config: MeshConfig) -> Self {
530 Self::new(config)
531 }
532}
533
534pub struct ModelDefaultsEditor<'a> {
535 defaults: &'a mut ModelConfigDefaults,
536}
537
538impl ModelDefaultsEditor<'_> {
539 pub fn runtime(&mut self, runtime: ModelRuntimeKind) -> &mut Self {
540 self.hardware().model_runtime = Some(runtime);
541 self
542 }
543
544 pub fn clear_runtime(&mut self) -> &mut Self {
545 self.hardware().model_runtime = None;
546 self
547 }
548
549 pub fn device(&mut self, device: impl Into<String>) -> &mut Self {
550 self.hardware().device = Some(device.into());
551 self
552 }
553
554 pub fn clear_device(&mut self) -> &mut Self {
555 self.hardware().device = None;
556 self
557 }
558
559 pub fn context_size(&mut self, context_size: Option<u32>) -> &mut Self {
560 self.model_fit().ctx_size = context_size;
561 self
562 }
563
564 pub fn parallel(&mut self, parallel: Option<usize>) -> &mut Self {
565 self.throughput().parallel = parallel;
566 self
567 }
568
569 fn hardware(&mut self) -> &mut HardwareConfig {
570 self.defaults.hardware.get_or_insert_with(Default::default)
571 }
572
573 fn model_fit(&mut self) -> &mut ModelFitConfig {
574 self.defaults.model_fit.get_or_insert_with(Default::default)
575 }
576
577 fn throughput(&mut self) -> &mut ThroughputConfig {
578 self.defaults
579 .throughput
580 .get_or_insert_with(Default::default)
581 }
582}
583
584pub struct ModelConfigEditor<'a> {
585 model: &'a mut ModelConfigEntry,
586}
587
588impl ModelConfigEditor<'_> {
589 pub fn model_ref(&self) -> &str {
590 &self.model.model
591 }
592
593 pub fn derived_profile(&self) -> String {
594 self.model.derived_profile()
595 }
596
597 pub fn runtime(&mut self, runtime: ModelRuntimeKind) -> &mut Self {
598 self.hardware().model_runtime = Some(runtime);
599 self
600 }
601
602 pub fn clear_runtime(&mut self) -> &mut Self {
603 self.hardware().model_runtime = None;
604 self
605 }
606
607 pub fn device(&mut self, device: impl Into<String>) -> &mut Self {
608 self.hardware().device = Some(device.into());
609 self
610 }
611
612 pub fn clear_device(&mut self) -> &mut Self {
613 self.hardware().device = None;
614 self
615 }
616
617 pub fn context_size(&mut self, context_size: u32) -> &mut Self {
618 self.model_fit().ctx_size = Some(context_size);
619 self
620 }
621
622 pub fn parallel(&mut self, parallel: usize) -> &mut Self {
623 self.throughput().parallel = Some(parallel);
624 self
625 }
626
627 pub fn cache_types(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
628 let model_fit = self.model_fit();
629 model_fit.cache_type_k = Some(key.into());
630 model_fit.cache_type_v = Some(value.into());
631 self
632 }
633
634 pub fn max_tokens(&mut self, max_tokens: u32) -> &mut Self {
635 self.request_defaults().max_tokens = Some(max_tokens);
636 self
637 }
638
639 pub fn temperature(&mut self, temperature: f64) -> &mut Self {
640 self.request_defaults().temperature = Some(temperature);
641 self
642 }
643
644 pub fn mmproj(&mut self, mmproj: impl Into<String>) -> &mut Self {
645 self.multimodal().mmproj = Some(mmproj.into());
646 self
647 }
648
649 fn hardware(&mut self) -> &mut HardwareConfig {
650 self.model.hardware.get_or_insert_with(Default::default)
651 }
652
653 fn model_fit(&mut self) -> &mut ModelFitConfig {
654 self.model.model_fit.get_or_insert_with(Default::default)
655 }
656
657 fn throughput(&mut self) -> &mut ThroughputConfig {
658 self.model.throughput.get_or_insert_with(Default::default)
659 }
660
661 fn request_defaults(&mut self) -> &mut RequestDefaultsConfig {
662 self.model
663 .request_defaults
664 .get_or_insert_with(Default::default)
665 }
666
667 fn multimodal(&mut self) -> &mut MultimodalConfig {
668 self.model.multimodal.get_or_insert_with(Default::default)
669 }
670}
671
672pub struct PluginConfigEditor<'a> {
673 plugin: &'a mut PluginConfigEntry,
674}
675
676impl PluginConfigEditor<'_> {
677 pub fn name(&self) -> &str {
678 &self.plugin.name
679 }
680
681 pub fn enabled(&mut self, enabled: bool) -> &mut Self {
682 self.plugin.enabled = Some(enabled);
683 self
684 }
685
686 pub fn web_ui_enabled(&mut self, enabled: Option<bool>) -> &mut Self {
687 self.plugin.web_ui_enabled = enabled;
688 self
689 }
690
691 pub fn command(&mut self, command: impl Into<String>) -> &mut Self {
692 self.plugin.command = Some(command.into());
693 self
694 }
695
696 pub fn args(&mut self, args: impl IntoIterator<Item = impl Into<String>>) -> &mut Self {
697 self.plugin.args = args.into_iter().map(Into::into).collect();
698 self
699 }
700
701 pub fn url(&mut self, url: impl Into<String>) -> &mut Self {
702 self.plugin.url = Some(url.into());
703 self
704 }
705
706 pub fn connect_timeout_secs(&mut self, seconds: u64) -> &mut Self {
707 self.plugin.startup.connect_timeout_secs = Some(seconds);
708 self
709 }
710
711 pub fn init_timeout_secs(&mut self, seconds: u64) -> &mut Self {
712 self.plugin.startup.init_timeout_secs = Some(seconds);
713 self
714 }
715
716 pub fn optional(&mut self, optional: bool) -> &mut Self {
717 self.plugin.startup.optional = optional;
718 self
719 }
720
721 pub fn lazy_start(&mut self, lazy_start: bool) -> &mut Self {
722 self.plugin.startup.lazy_start = lazy_start;
723 self
724 }
725}
726
727fn normalize_non_empty(value: &str, label: &str) -> Result<String> {
728 let value = value.trim();
729 if value.is_empty() {
730 bail!("{label} cannot be empty");
731 }
732 Ok(value.to_string())
733}
734
735#[cfg(test)]
736mod schema_tests {
737 use super::*;
738 use crate::{
739 ConfigAliasPolicy, ConfigConditionOperator, ConfigConditionValue, ConfigPathAliasKind,
740 ConfigVisibility, config_to_toml, parse_config_toml,
741 };
742 use toml::Value;
743
744 #[test]
745 fn schema_setting_builder_populates_control_surface_metadata() {
746 let mut setting = ConfigSettingSchemaBuilder::new(
747 ConfigPath::from_fields(["owner_control", "bind"]),
748 ConfigValueSchema::SocketAddr,
749 );
750 setting
751 .owner(ConfigSettingOwner::BuiltIn)
752 .support(ConfigSupportState::Supported)
753 .control_surface(ConfigControlSurface::ConfigFile)
754 .control_surface(ConfigControlSurface::OwnerControl)
755 .apply_mode(ConfigApplyMode::DynamicApply)
756 .restart_scope(ConfigRestartScope::ProcessRestart)
757 .visibility(ConfigVisibility::Advanced)
758 .description("Owner control listener bind address")
759 .constraint(ConfigConstraint::NonEmpty)
760 .alias(ConfigPathAlias {
761 path: ConfigPath::from_fields(["owner_control", "listen"]),
762 kind: ConfigPathAliasKind::LegacyKey,
763 note: Some("legacy naming preserved for diagnostics".into()),
764 });
765
766 let built = setting.build();
767
768 assert_eq!(built.path.render(), "owner_control.bind");
769 assert_eq!(
770 built.alias_policy.mode,
771 ConfigAliasMode::CanonicalWithLegacyAliases
772 );
773 assert_eq!(built.alias_policy.aliases.len(), 1);
774 assert_eq!(built.control_surfaces.len(), 2);
775 assert_eq!(built.apply_mode, ConfigApplyMode::DynamicApply);
776 assert_eq!(built.restart_scope, ConfigRestartScope::ProcessRestart);
777 assert_eq!(built.visibility, ConfigVisibility::Advanced);
778 }
779
780 #[test]
781 fn schema_builder_collects_settings() {
782 let mut schema = ConfigSchemaBuilder::new();
783 let mut setting = ConfigSettingSchemaBuilder::new(
784 ConfigPath::from_fields(["telemetry", "endpoint"]),
785 ConfigValueSchema::String,
786 );
787 setting
788 .owner(ConfigSettingOwner::BuiltIn)
789 .control_surface(ConfigControlSurface::ConfigFile);
790 schema.setting(setting.build());
791
792 let built = schema.build();
793
794 assert_eq!(built.settings.len(), 1);
795 assert_eq!(built.settings[0].path.render(), "telemetry.endpoint");
796 }
797
798 #[test]
799 fn schema_setting_builder_control_behavior_matches_hand_constructed_json() {
800 let enable_condition = ConfigControlCondition {
801 path: ConfigPath::from_fields(["gpu", "assignment"]),
802 operator: ConfigConditionOperator::Equals,
803 values: vec![ConfigConditionValue::String("pinned".to_string())],
804 };
805 let disable_condition = ConfigConditionalDisable {
806 condition: ConfigControlCondition {
807 path: ConfigPath::from_fields(["owner_control", "bind"]),
808 operator: ConfigConditionOperator::Absent,
809 values: Vec::new(),
810 },
811 reason: "Owner control bind is required".to_string(),
812 note: Some("Preserve the existing value until bind is configured".to_string()),
813 write_policy: ConfigDisabledWritePolicy::OmitWhenDisabled,
814 };
815 let conflict = ConfigConflictRule {
816 group: "gpu-selection".to_string(),
817 condition: ConfigControlCondition {
818 path: ConfigPath::from_fields(["defaults", "hardware", "gpu_id"]),
819 operator: ConfigConditionOperator::Present,
820 values: Vec::new(),
821 },
822 reason: "Choose either a runtime GPU selector or a pinned GPU id".to_string(),
823 preferred_path: Some(ConfigPath::from_fields(["gpu", "assignment"])),
824 };
825 let expected_behavior = ConfigControlBehavior {
826 numeric: Some(ConfigNumericControl {
827 min: Some(1.0),
828 max: Some(8.0),
829 step: Some(1.0),
830 soft_min: Some(1.0),
831 soft_max: Some(4.0),
832 unit: Some("gpus".to_string()),
833 }),
834 text_format: Some(ConfigTextFormat::Path),
835 options_source: Some(ConfigOptionsSource::RuntimeGpus),
836 availability: Some(ConfigControlAvailability {
837 enabled: false,
838 reason: Some("GPU inventory is unavailable".to_string()),
839 note: Some(
840 "The current value is preserved until runtime inventory returns".to_string(),
841 ),
842 source: ConfigControlAvailabilitySource::Runtime,
843 }),
844 enable_when: vec![enable_condition.clone()],
845 disable_when: vec![disable_condition.clone()],
846 conflicts: vec![conflict.clone()],
847 write_policy: Some(ConfigDisabledWritePolicy::RejectWhenDisabled),
848 };
849 let hand_constructed = ConfigSettingSchema {
850 path: ConfigPath::from_fields(["gpu", "parallel"]),
851 alias_policy: ConfigAliasPolicy::default(),
852 owner: ConfigSettingOwner::BuiltIn,
853 value_schema: ConfigValueSchema::Integer,
854 support: ConfigSupportState::Supported,
855 control_surfaces: Vec::new(),
856 apply_mode: ConfigApplyMode::StaticOnLoad,
857 restart_scope: ConfigRestartScope::None,
858 visibility: ConfigVisibility::User,
859 constraints: Vec::new(),
860 description: None,
861 presentation: None,
862 control_behavior: Some(expected_behavior.clone()),
863 };
864 let mut builder = ConfigSettingSchemaBuilder::new(
865 ConfigPath::from_fields(["gpu", "parallel"]),
866 ConfigValueSchema::Integer,
867 );
868 builder
869 .control_numeric_min(1.0)
870 .control_numeric_max(8.0)
871 .control_numeric_step(1.0)
872 .control_numeric_soft_min(1.0)
873 .control_numeric_soft_max(4.0)
874 .control_numeric_unit("gpus")
875 .control_text_format(ConfigTextFormat::Path)
876 .control_options_runtime_gpus()
877 .control_availability_enabled(false)
878 .control_availability_source(ConfigControlAvailabilitySource::Runtime)
879 .control_availability_reason("GPU inventory is unavailable")
880 .control_availability_note(
881 "The current value is preserved until runtime inventory returns",
882 )
883 .control_enable_when(enable_condition)
884 .control_disable_when(disable_condition)
885 .control_conflict(conflict)
886 .control_write_policy(ConfigDisabledWritePolicy::RejectWhenDisabled);
887
888 let built = builder.build();
889
890 assert_eq!(built.control_behavior, Some(expected_behavior));
891 assert_eq!(
892 Value::try_from(built).expect("built setting should serialize"),
893 Value::try_from(hand_constructed).expect("hand-constructed setting should serialize")
894 );
895 }
896
897 #[test]
898 fn schema_setting_builder_runtime_gpu_option_helper_sets_runtime_source() {
899 let mut setting = ConfigSettingSchemaBuilder::new(
900 ConfigPath::from_fields(["defaults", "hardware", "device"]),
901 ConfigValueSchema::String,
902 );
903 setting.control_options_runtime_gpus();
904
905 let built = setting.build();
906
907 assert_eq!(
908 built
909 .control_behavior
910 .and_then(|behavior| behavior.options_source),
911 Some(ConfigOptionsSource::RuntimeGpus)
912 );
913 }
914
915 #[test]
916 fn schema_setting_builder_no_helper_serialization_omits_control_behavior() {
917 let setting = ConfigSettingSchemaBuilder::new(
918 ConfigPath::from_fields(["telemetry", "endpoint"]),
919 ConfigValueSchema::String,
920 )
921 .build();
922
923 let serialized = Value::try_from(setting).expect("setting should serialize");
924 let table = serialized
925 .as_table()
926 .expect("setting should serialize to a table");
927
928 assert!(!table.contains_key("control_behavior"));
929 }
930
931 #[test]
932 fn schema_setting_builder_direct_control_behavior_can_be_extended_deterministically() {
933 let mut setting = ConfigSettingSchemaBuilder::new(
934 ConfigPath::from_fields(["defaults", "request_defaults", "temperature"]),
935 ConfigValueSchema::Float,
936 );
937 setting
938 .control_behavior(ConfigControlBehavior {
939 numeric: None,
940 text_format: Some(ConfigTextFormat::Plain),
941 options_source: None,
942 availability: None,
943 enable_when: Vec::new(),
944 disable_when: Vec::new(),
945 conflicts: Vec::new(),
946 write_policy: None,
947 })
948 .control_numeric(ConfigNumericControl {
949 min: Some(0.0),
950 max: Some(2.0),
951 step: Some(0.1),
952 soft_min: None,
953 soft_max: None,
954 unit: None,
955 })
956 .control_options_static();
957
958 let built = setting.build();
959 let behavior = built
960 .control_behavior
961 .expect("control behavior should be present");
962
963 assert_eq!(behavior.text_format, Some(ConfigTextFormat::Plain));
964 assert_eq!(
965 behavior.numeric,
966 Some(ConfigNumericControl {
967 min: Some(0.0),
968 max: Some(2.0),
969 step: Some(0.1),
970 soft_min: None,
971 soft_max: None,
972 unit: None,
973 })
974 );
975 assert_eq!(behavior.options_source, Some(ConfigOptionsSource::Static));
976 }
977
978 #[test]
979 fn schema_setting_builder_static_availability_and_dependency_disable_are_deterministic() {
980 let dependency_disable = ConfigConditionalDisable {
981 condition: ConfigControlCondition {
982 path: ConfigPath::from_fields(["owner_control", "bind"]),
983 operator: ConfigConditionOperator::Absent,
984 values: Vec::new(),
985 },
986 reason: "Owner control bind is required".to_string(),
987 note: None,
988 write_policy: ConfigDisabledWritePolicy::OmitWhenDisabled,
989 };
990 let mut setting = ConfigSettingSchemaBuilder::new(
991 ConfigPath::from_fields(["owner_control", "advertise_addr"]),
992 ConfigValueSchema::SocketAddr,
993 );
994 setting
995 .control_availability_enabled(false)
996 .control_availability_source(ConfigControlAvailabilitySource::Static)
997 .control_availability_reason("Owner control is disabled for this build")
998 .control_disable_when(dependency_disable.clone());
999
1000 let built = setting.build();
1001 let behavior = built
1002 .control_behavior
1003 .as_ref()
1004 .expect("control behavior should be present");
1005 let availability = behavior
1006 .availability
1007 .as_ref()
1008 .expect("availability metadata should be present");
1009
1010 assert!(!availability.enabled);
1011 assert_eq!(availability.source, ConfigControlAvailabilitySource::Static);
1012 assert_eq!(
1013 built.default_disabled_write_policy(Some(availability.source)),
1014 Some(ConfigDisabledWritePolicy::PreserveExisting)
1015 );
1016 assert_eq!(behavior.disable_when, vec![dependency_disable]);
1017 assert_eq!(
1018 behavior.disable_when[0].write_policy,
1019 ConfigDisabledWritePolicy::OmitWhenDisabled
1020 );
1021 }
1022
1023 #[test]
1024 fn model_config_entry_roundtrips_with_derived_profile() {
1025 let mut editor = ConfigEditor::new(MeshConfig::default());
1026 editor
1027 .upsert_model("Qwen/Qwen3-8B-GGUF:Q4_K_M", String::new())
1028 .unwrap()
1029 .context_size(4096);
1030 editor
1031 .upsert_model("Qwen/Qwen3-8B-GGUF:Q4_K_M", String::new())
1032 .unwrap()
1033 .context_size(16384);
1034
1035 let config = editor.into_config();
1036 let serialized = config_to_toml(&config).expect("should serialize");
1037 let deserialized = parse_config_toml(&serialized).expect("should deserialize");
1038
1039 assert_eq!(deserialized.models.len(), 2);
1040 let profiles: Vec<String> = deserialized
1041 .models
1042 .iter()
1043 .map(|e| e.derived_profile())
1044 .collect();
1045 let profile_strs: Vec<&str> = profiles.iter().map(|s| s.as_str()).collect();
1046 assert_ne!(
1047 profile_strs[0], profile_strs[1],
1048 "different ctx_size must produce different derived profiles"
1049 );
1050 }
1051
1052 #[test]
1053 fn model_config_entry_without_profile_omits_profile_key() {
1054 let mut editor = ConfigEditor::new(MeshConfig::default());
1055 editor
1056 .upsert_model("Qwen/Qwen3-8B-GGUF:Q4_K_M", String::new())
1057 .unwrap()
1058 .context_size(8192);
1059
1060 let config = editor.into_config();
1061 let serialized = config_to_toml(&config).expect("should serialize");
1062 let toml_str = serialized.to_string();
1063
1064 assert!(!toml_str.contains("profile"));
1065 let deserialized = parse_config_toml(&serialized).expect("should deserialize");
1066 assert_eq!(deserialized.models.len(), 1);
1067 assert_eq!(deserialized.models[0].model, "Qwen/Qwen3-8B-GGUF:Q4_K_M");
1068 assert!(!deserialized.models[0].derived_profile().is_empty());
1069 }
1070
1071 #[test]
1072 fn upsert_model_dedup_by_derived_profile() {
1073 let mut editor = ConfigEditor::new(MeshConfig::default());
1074 editor
1075 .upsert_model("Qwen3-8B", String::new())
1076 .unwrap()
1077 .context_size(4096);
1078 editor
1079 .upsert_model("Qwen3-8B", String::new())
1080 .unwrap()
1081 .context_size(8192);
1082
1083 let config = editor.into_config();
1084 assert_eq!(config.models.len(), 2);
1085 }
1086
1087 #[test]
1088 fn upsert_model_dedup_same_config() {
1089 let mut editor = ConfigEditor::new(MeshConfig::default());
1090 let mut model_a = editor.upsert_model("Qwen3-8B", String::new()).unwrap();
1091 model_a.context_size(4096);
1092 let profile_str = model_a.derived_profile();
1093 editor
1094 .upsert_model("Qwen3-8B", profile_str)
1095 .unwrap()
1096 .context_size(8192);
1097
1098 let config = editor.into_config();
1099 assert_eq!(config.models.len(), 1);
1100 assert_eq!(
1101 config.models[0].model_fit.as_ref().unwrap().ctx_size,
1102 Some(8192)
1103 );
1104 }
1105
1106 #[test]
1107 fn upsert_model_coexists_with_different_config() {
1108 let mut editor = ConfigEditor::new(MeshConfig::default());
1109 editor
1110 .upsert_model("Qwen3-8B", String::new())
1111 .unwrap()
1112 .context_size(4096);
1113 editor
1114 .upsert_model("Qwen3-8B", String::new())
1115 .unwrap()
1116 .context_size(8192);
1117
1118 let config = editor.into_config();
1119 assert_eq!(config.models.len(), 2);
1121 }
1122
1123 #[test]
1124 fn remove_model_by_derived_profile() {
1125 let mut editor = ConfigEditor::new(MeshConfig::default());
1126 editor
1127 .upsert_model("Qwen3-8B", String::new())
1128 .unwrap()
1129 .context_size(4096);
1130 {
1131 let mut e = editor.upsert_model("Qwen3-8B", String::new()).unwrap();
1132 e.context_size(16384);
1133 }
1134 editor.upsert_model("Qwen3-8B", String::new()).unwrap();
1135
1136 assert_eq!(editor.into_config().models.len(), 3);
1137
1138 let mut editor = ConfigEditor::new(MeshConfig::default());
1140 editor
1141 .upsert_model("Qwen3-8B", String::new())
1142 .unwrap()
1143 .context_size(4096);
1144 let high_ctx_profile = {
1145 let mut e = editor.upsert_model("Qwen3-8B", String::new()).unwrap();
1146 e.context_size(16384);
1147 e.derived_profile()
1148 };
1149 editor.upsert_model("Qwen3-8B", String::new()).unwrap();
1150
1151 editor.remove_model("Qwen3-8B", high_ctx_profile).unwrap();
1152
1153 let config = editor.into_config();
1154 assert_eq!(config.models.len(), 2);
1155 }
1156
1157 #[test]
1158 fn backwards_compat_parse_model_without_profile_field() {
1159 let toml_str = r#"
1160version = 1
1161
1162[[models]]
1163model = "Qwen/Qwen3-8B-GGUF:Q4_K_M"
1164runtime = "metal"
1165
1166[models.model_fit]
1167ctx_size = 8192
1168"#;
1169
1170 let config = parse_config_toml(toml_str).expect("should parse");
1171 assert_eq!(config.models.len(), 1);
1172 assert_eq!(config.models[0].model, "Qwen/Qwen3-8B-GGUF:Q4_K_M");
1173 assert!(!config.models[0].derived_profile().is_empty());
1174 assert_eq!(
1175 config.models[0].model_fit.as_ref().unwrap().ctx_size,
1176 Some(8192)
1177 );
1178
1179 let serialized = config_to_toml(&config).expect("should serialize");
1180 let deserialized = parse_config_toml(&serialized).expect("should re-parse");
1181 assert_eq!(deserialized.models.len(), 1);
1182 assert_eq!(
1183 deserialized.models[0].derived_profile(),
1184 config.models[0].derived_profile()
1185 );
1186 }
1187
1188 #[test]
1189 fn plugin_web_ui_preference_roundtrips_absence_and_explicit_values() {
1190 let mut editor = ConfigEditor::new(MeshConfig::default());
1191 editor.upsert_plugin("default-ui").unwrap();
1192 editor
1193 .upsert_plugin("hidden-ui")
1194 .unwrap()
1195 .web_ui_enabled(Some(false));
1196 editor
1197 .upsert_plugin("shown-ui")
1198 .unwrap()
1199 .web_ui_enabled(Some(true));
1200
1201 let config = editor.into_config();
1202 let serialized = config_to_toml(&config).expect("should serialize");
1203 let toml_str = serialized.to_string();
1204
1205 assert!(!toml_str.contains("default-ui\"\nweb_ui_enabled"));
1206 assert!(toml_str.contains("name = \"hidden-ui\"\nweb_ui_enabled = false"));
1207 assert!(toml_str.contains("name = \"shown-ui\"\nweb_ui_enabled = true"));
1208
1209 let deserialized = parse_config_toml(&serialized).expect("should deserialize");
1210 assert_eq!(deserialized.plugins[0].web_ui_enabled, None);
1211 assert_eq!(deserialized.plugins[1].web_ui_enabled, Some(false));
1212 assert_eq!(deserialized.plugins[2].web_ui_enabled, Some(true));
1213 }
1214
1215 #[test]
1216 fn plugin_web_ui_preference_does_not_mutate_runtime_enabled_flag() {
1217 let mut editor = ConfigEditor::new(MeshConfig::default());
1218 editor
1219 .upsert_plugin("blackboard")
1220 .unwrap()
1221 .enabled(true)
1222 .web_ui_enabled(Some(false));
1223
1224 let config = editor.into_config();
1225
1226 assert_eq!(config.plugins[0].enabled, Some(true));
1227 assert_eq!(config.plugins[0].web_ui_enabled, Some(false));
1228 }
1229
1230 #[test]
1231 fn plugin_web_ui_preference_can_clear_explicit_choice_without_disabling_plugin() {
1232 let mut editor = ConfigEditor::new(MeshConfig::default());
1233 editor
1234 .upsert_plugin("blackboard")
1235 .unwrap()
1236 .enabled(true)
1237 .web_ui_enabled(Some(false));
1238 editor
1239 .upsert_plugin("blackboard")
1240 .unwrap()
1241 .web_ui_enabled(None);
1242
1243 let config = editor.into_config();
1244
1245 assert_eq!(config.plugins[0].enabled, Some(true));
1246 assert_eq!(config.plugins[0].web_ui_enabled, None);
1247 }
1248}