Skip to main content

mesh_llm_config/
authoring.rs

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        // Different ctx_size → different derived profile → both coexist.
1120        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        // Re-create editor for the remove step (into_config consumes self).
1139        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}