Skip to main content

gglib_core/
settings.rs

1//! Settings domain types and validation.
2//!
3//! This module contains the core settings types used across the application.
4//! These are pure domain types with no infrastructure dependencies.
5
6use serde::{Deserialize, Serialize};
7
8use crate::domain::{InferenceConfig, InferenceProfile};
9
10#[path = "settings_loop_guard.rs"]
11mod settings_loop_guard;
12pub use settings_loop_guard::LoopGuardMode;
13
14#[path = "settings_update.rs"]
15mod settings_update;
16pub use settings_update::{SettingsError, SettingsUpdate};
17
18#[path = "settings_validate.rs"]
19mod settings_validate;
20pub use settings_validate::{validate_inference_config, validate_inference_profiles};
21
22#[path = "settings_remote.rs"]
23mod settings_remote;
24pub use settings_remote::{Device, RemotePairing, RemoteServe};
25
26/// Default port for the OpenAI-compatible proxy server.
27pub const DEFAULT_PROXY_PORT: u16 = 8080;
28
29/// Fixed loopback port for the gglib daemon's management API.
30///
31/// Deliberately a compile-time constant rather than a setting: the daemon is
32/// the one process every client (CLI, desktop app, browser dashboard) must be
33/// able to find without configuration, and a configurable port would reopen
34/// the "two daemons on different ports" split-brain this constant closes.
35pub const DAEMON_PORT: u16 = 9887;
36
37/// Default base port for llama-server instance allocation.
38pub const DEFAULT_LLAMA_BASE_PORT: u16 = 9000;
39
40/// The loopback port `gglib remote join` tries first for the paired
41/// machine.
42///
43/// A client configured against it once stays configured. Clear of the proxy
44/// (8080), the daemon (9887) and the llama-server range (9000 upward); taken
45/// by something else, the next free port is used and remembered instead.
46pub const DEFAULT_REMOTE_PORT: u16 = 8180;
47
48/// Default context size for models when not specified by the user.
49pub const DEFAULT_CONTEXT_SIZE: u64 = 4096;
50
51/// The context sizes a person is allowed to configure.
52///
53/// One constant because more than one surface describes this range and they
54/// have to agree — [`validate_settings`] rejects anything outside it, and so do
55/// the flags that write this setting or default it. Spelling the numbers out
56/// separately on each is how they drift.
57///
58/// Not every context-size flag is bounded by it: `--ctx-size` names a
59/// per-launch value rather than this setting, and `CtxSizeArg::parse` accepts
60/// any `u64`. That is a separate surface with a separate contract, not an
61/// omission here.
62pub const CONTEXT_SIZE_RANGE: std::ops::RangeInclusive<u64> = 512..=1_000_000;
63
64/// Application settings structure.
65///
66/// All fields are optional to support partial updates and graceful defaults.
67#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
68#[serde(default)]
69pub struct Settings {
70    /// Default directory for downloading models.
71    pub default_download_path: Option<String>,
72
73    /// Default context size for models (e.g., 8192, 32768).
74    ///
75    /// `None` means the user has chosen nothing, and is the ordinary state —
76    /// it is what lets the daemon size each launch rather than pinning it.
77    /// A value here is read as a number the user typed and outranks that, so
78    /// nothing writes one on their behalf and `settings unset` returns it. See
79    /// [`crate::server_config::resolve_context_size_with_source`] for the chain
80    /// and `Self::with_defaults` for why this field is the one left unset.
81    pub default_context_size: Option<u64>,
82
83    /// Port for the OpenAI-compatible proxy server.
84    pub proxy_port: Option<u16>,
85
86    /// Base port for llama-server instance allocation (first port in range).
87    /// Note: The OpenAI-compatible proxy listens on `proxy_port`.
88    pub llama_base_port: Option<u16>,
89
90    /// Maximum number of downloads that can be queued (1-50).
91    pub max_download_queue_size: Option<u32>,
92
93    /// Whether to show memory fit indicators in `HuggingFace` browser.
94    pub show_memory_fit_indicators: Option<bool>,
95
96    /// Maximum iterations for tool calling agentic loop.
97    pub max_tool_iterations: Option<u32>,
98
99    /// Maximum stagnation steps before stopping agent loop.
100    pub max_stagnation_steps: Option<u32>,
101
102    /// Default model ID for commands that support a default model.
103    pub default_model_id: Option<i64>,
104
105    /// Global inference parameter defaults.
106    ///
107    /// Applied when neither request nor per-model defaults are specified.
108    /// If not set, hardcoded defaults are used as final fallback.
109    #[serde(default)]
110    pub inference_defaults: Option<InferenceConfig>,
111
112    /// Named sampling profiles, selectable per request as `{model}:{profile}`.
113    ///
114    /// Global rather than per-model: one `coding` profile applies to every
115    /// model, and its sparse fields fall through to that model's own
116    /// `inference_defaults` for anything it does not set. See
117    /// [`crate::domain::inference_profile`].
118    #[serde(default)]
119    pub inference_profiles: Option<Vec<InferenceProfile>>,
120
121    // ── Setup wizard ────────────────────────────────────────────────
122    /// Whether the first-run setup wizard has been completed.
123    pub setup_completed: Option<bool>,
124
125    /// Custom prompt template for generating chat titles.
126    pub title_generation_prompt: Option<String>,
127
128    // ── Network binding ─────────────────────────────────────────────
129    /// Override the bind host for `gglib web`.
130    ///
131    /// `None` → use the compiled-in default (`127.0.0.1`). The `--host` flag
132    /// takes precedence for a single run without changing this value.
133    pub bind_host: Option<String>,
134
135    /// Whether `gglib web` binds all LAN interfaces and broadcasts over mDNS.
136    ///
137    /// `None`/`Some(false)` → localhost-only. The `--share-lan` flag can turn
138    /// this on for a single run, but cannot turn it off — clear it here.
139    pub share_lan: Option<bool>,
140
141    /// Bearer token required on the proxy's `/v1/*` and `/mcp` routes.
142    ///
143    /// `None` leaves the endpoint unauthenticated, which is the historical
144    /// behaviour and remains the default for a loopback bind. The proxy mints
145    /// one here automatically the first time it binds a non-loopback host, so
146    /// an endpoint that reaches a network is never left open by omission.
147    ///
148    /// `--api-key` and `GGLIB_API_KEY` override this for a single run without
149    /// changing it. The desktop app reads it from here — that is how the GUI
150    /// dashboard authenticates against the proxy it started.
151    pub proxy_api_key: Option<String>,
152
153    // ── Sampling authority ──────────────────────────────────────────
154    /// Whether a client's own sampling parameters (`temperature`, `top_p`,
155    /// `top_k`, `presence_penalty`, `repeat_penalty`, `min_p`) are honoured
156    /// by the proxy at all.
157    ///
158    /// `None`/`Some(false)` → the client's sampling opinions are dropped from
159    /// the resolution hierarchy entirely; the request falls straight through
160    /// to the profile / per-model / global / floor layers as if the client
161    /// had sent none of them.
162    ///
163    /// The carve-out is a *category*, not one exception: the client's own
164    /// **budgets** are unaffected either way, because a budget says what the
165    /// request *is* rather than how it should sample. `max_tokens` was the
166    /// only member for a long time — ignoring it would silently truncate that
167    /// client's own turns — and `reasoning_budget_tokens` joined it, capping
168    /// what this turn may spend thinking within a range llama.cpp itself
169    /// enforces. The list is
170    /// [`CLIENT_AUTHORITATIVE_KEYS`](crate::request_pipeline::CLIENT_AUTHORITATIVE_KEYS),
171    /// which carries the rule for what may join it; this doc names members
172    /// rather than owning them.
173    ///
174    /// Defaults to distrust because most clients that talk to this proxy
175    /// send fixed sampling values with no user-facing control behind them —
176    /// boilerplate the client always sends, not a deliberate choice by
177    /// whoever is using it (VS Code Copilot's LLM Gateway hardcodes
178    /// `temperature: 0` on every request, for one). Letting that boilerplate
179    /// silently outrank a model's own tuned defaults and this server's
180    /// global settings defeats the point of configuring either. Set `true`
181    /// for a client that does expose real sampling controls to its user
182    /// (`OpenWebUI`'s sliders, for instance).
183    pub trust_client_sampling: Option<bool>,
184
185    // ── Proxy loop guard ────────────────────────────────────────────
186    /// What the proxy's turn-level loop/stagnation guard does on
187    /// `/v1/chat/completions` when a replayed history trips it.
188    ///
189    /// A conversation that repeats the same tool-call batch back to back and
190    /// gets the same answer back each time, or repeats the same assistant
191    /// response anywhere in the session, beyond the shared agent-path
192    /// thresholds, is answered per [`LoopGuardMode`]: `note` (absent, and the
193    /// default) forwards it with a note saying what repeated, `refuse` rejects
194    /// it with a clean HTTP 400 before admission, and `off` does not scan.
195    /// Replaying identical batches across a history does not trip it — the
196    /// batch count is back to back — and a repeat whose answer changed is not
197    /// counted at all.
198    ///
199    /// Note the polarity: absent means the guard is **on**, because it is
200    /// protection the endpoint should not silently lose, unlike
201    /// [`Self::trust_client_sampling`], which is authority a client must be
202    /// explicitly granted.
203    ///
204    /// The stagnation threshold itself comes from
205    /// [`Self::max_stagnation_steps`], shared with the built-in agent loop so
206    /// the two paths cannot drift.
207    ///
208    /// Read through [`Self::effective_loop_guard_mode`], never directly: the
209    /// deprecated [`Self::proxy_loop_detection`] still answers for a settings
210    /// file written by an older build.
211    pub loop_guard_mode: Option<LoopGuardMode>,
212
213    /// **Deprecated**, for one release: the boolean [`Self::loop_guard_mode`]
214    /// replaces.
215    ///
216    /// `Some(false)` still means [`LoopGuardMode::Off`]. `Some(true)` means
217    /// the guard is on, which is now [`LoopGuardMode::Note`] rather than a
218    /// refusal — a deliberate behaviour change for anyone who asked for the
219    /// guard by name, and the point of #1052.
220    ///
221    /// The two never disagree on disk: [`Self::merge`] clears each when the
222    /// other is **written to a value** — clearing one leaves the other alone,
223    /// since an explicit null means "forget this field", not "forget both" —
224    /// so precedence is only ever consulted for a settings file an older build
225    /// wrote. `gglib config settings set
226    /// --proxy-loop-detection false` therefore keeps working for the release
227    /// it is promised, for anyone who scripted it while the guard's own 400
228    /// bodies still named it.
229    pub proxy_loop_detection: Option<bool>,
230
231    /// Whether a tool call that fails schema validation is re-issued, with
232    /// `tool_choice: "required"` or as a second draw under gglib's grammar.
233    ///
234    /// `None` (the default) means **on**, the same inverse polarity as
235    /// [`Self::proxy_loop_detection`] and for the same reason: it is
236    /// protection the endpoint should not lose silently. `Some(false)`
237    /// forwards every call as emitted.
238    ///
239    /// Worth turning off only for a client that depends on receiving the
240    /// model's literal output — the repair costs one extra generation on a
241    /// failed call, and nothing on a conformant one. The
242    /// `GGLIB_DISABLE_TOOL_REPAIR` environment switch reaches the same gate
243    /// without persisting a setting.
244    ///
245    /// See [Tool-call repair](https://github.com/mmogr/gglib/blob/main/docs/tool-call-repair.md).
246    pub tool_call_repair: Option<bool>,
247
248    // ── Agentic-turn sampling ───────────────────────────────────────
249    /// Whether a request carrying tools gets the agentic-turn temperature
250    /// ceiling — see
251    /// [`InferenceConfig::agentic_temperature_ceiling`](crate::domain::InferenceConfig::agentic_temperature_ceiling).
252    ///
253    /// `None`/`Some(true)` → active (the default): a turn that may emit
254    /// structured output has its temperature capped, but only over a value
255    /// nobody deliberately chose — an auto-detected recipe or the floor —
256    /// and only on a model class that still has a ceiling. Since the
257    /// 2026-08-10 measurement (see `agentic_temperature_ceiling`) reasoning
258    /// models have none, so on them this setting currently gates nothing.
259    /// Anything set by a person stands. `Some(false)` disables the cap.
260    ///
261    /// Same polarity as [`Self::proxy_loop_detection`], and for the same
262    /// reason: this is a correction the endpoint should not silently lose.
263    ///
264    /// The `tool_call_floor` alias is the name this shipped under briefly in
265    /// #741, before verification showed the adjustment fires on every agentic
266    /// turn rather than only on tool emission. Kept so a config written in
267    /// that window still loads.
268    #[serde(alias = "tool_call_floor")]
269    pub agentic_sampling: Option<bool>,
270
271    // ── Always-on proxy (desktop app) ───────────────────────────────
272    /// Whether the desktop app starts the OpenAI-compatible proxy as soon as
273    /// it launches, rather than waiting for the user to switch it on.
274    ///
275    /// This is what makes the proxy a background service rather than a
276    /// feature you remember to enable: combined with [`Self::start_at_login`]
277    /// and [`Self::close_to_tray`], the endpoint is simply always there for
278    /// clients like VS Code Copilot, with no terminal held open.
279    ///
280    /// Read by the desktop app only. `gglib proxy` and `gglib serve` are
281    /// explicit foreground commands — starting a second proxy underneath them
282    /// would contend for the same port.
283    pub proxy_autostart: Option<bool>,
284
285    /// Whether closing the desktop app's window hides it to the system tray
286    /// instead of quitting.
287    ///
288    /// `None`/`Some(false)` → closing the window shuts the app down, stopping
289    /// the proxy and any running llama-server with it (the historical
290    /// behaviour). `Some(true)` → the window hides and the app keeps serving;
291    /// quitting is then an explicit action from the tray menu.
292    pub close_to_tray: Option<bool>,
293
294    /// Whether the desktop app registers itself to launch on login.
295    ///
296    /// Backed by the OS autostart mechanism for each platform (macOS login
297    /// item, Windows `Run` key, XDG autostart entry on Linux). Toggling this
298    /// registers or unregisters immediately rather than at next launch, so the
299    /// stored value and the OS state cannot drift apart.
300    pub start_at_login: Option<bool>,
301
302    // ── Remote tunnel, connect side (ADR 0012) ──────────────────────
303    /// The machine this one paired with, and the key it issued — see
304    /// [`RemotePairing`] for why those are one value and not two.
305    ///
306    /// Received, not chosen: `gglib remote join` redeems the far
307    /// machine's one-time code through the tunnel and stores what comes back
308    /// here, so later sessions need only the ticket — or nothing, since the
309    /// ticket is part of the record. `gglib q --remote` and
310    /// `gglib chat --remote` attach the key as the bearer. Nothing writes it
311    /// by hand, and `gglib config settings show` reports the key as held or
312    /// not rather than printing it, because re-pairing replaces it and
313    /// nothing needs to read it back.
314    ///
315    /// A database written before the halves were bound holds
316    /// `remote_api_key` and `remote_last_ticket` as separate rows, and both
317    /// are ignored — no alias, deliberately. Neither is evidence about the
318    /// other, and reading the one as belonging to the other is exactly the
319    /// defect this field closes; such a machine loads as never paired and
320    /// pairs again, which a stale ticket already required of it.
321    pub remote_pairing: Option<RemotePairing>,
322
323    /// Reachable across restarts, and how — see [`RemoteServe`].
324    pub remote_enabled: Option<bool>,
325    /// See [`RemoteServe`].
326    pub remote_serve: Option<RemoteServe>,
327    /// The roster of paired devices, keys excluded — see [`Device`].
328    pub remote_devices: Option<Vec<Device>>,
329}
330
331impl Settings {
332    /// Create settings with sensible defaults.
333    #[must_use]
334    pub const fn with_defaults() -> Self {
335        Self {
336            default_download_path: None,
337            // `None`, not the floor. This is what `gglib config settings
338            // reset` writes, and a stored value is the evidence that the user
339            // chose a number — the settings modal shows an empty box when
340            // unset and writes back blank. Writing 4096 here fabricated that
341            // evidence, and the global-default rung outranks the fitted one,
342            // so a reset pinned the user above the context #925 computes for
343            // their machine. The rungs below have no such problem: nothing
344            // sits under `proxy_port` or `llama_base_port` to be shadowed.
345            default_context_size: None,
346            proxy_port: Some(DEFAULT_PROXY_PORT),
347            llama_base_port: Some(DEFAULT_LLAMA_BASE_PORT),
348            max_download_queue_size: Some(10),
349            show_memory_fit_indicators: Some(true),
350            #[allow(clippy::cast_possible_truncation)] // compile-time constants, always < u32::MAX
351            max_tool_iterations: Some(crate::domain::agent::DEFAULT_MAX_ITERATIONS as u32),
352            #[allow(clippy::cast_possible_truncation)]
353            max_stagnation_steps: Some(crate::domain::agent::DEFAULT_MAX_STAGNATION_STEPS as u32),
354            agentic_sampling: None,
355            default_model_id: None,
356            inference_defaults: None,
357            inference_profiles: None,
358            setup_completed: None,
359            title_generation_prompt: None,
360            bind_host: None,
361            share_lan: None,
362            proxy_api_key: None,
363            trust_client_sampling: None,
364            loop_guard_mode: None,
365            proxy_loop_detection: None,
366            tool_call_repair: None,
367            proxy_autostart: None,
368            close_to_tray: None,
369            start_at_login: None,
370            remote_pairing: None,
371            remote_enabled: None,
372            remote_serve: None,
373            remote_devices: None,
374        }
375    }
376
377    /// Get the effective proxy port (with default fallback).
378    #[must_use]
379    pub const fn effective_proxy_port(&self) -> u16 {
380        match self.proxy_port {
381            Some(port) => port,
382            None => DEFAULT_PROXY_PORT,
383        }
384    }
385
386    /// Get the effective llama-server base port (with default fallback).
387    #[must_use]
388    pub const fn effective_llama_base_port(&self) -> u16 {
389        match self.llama_base_port {
390            Some(port) => port,
391            None => DEFAULT_LLAMA_BASE_PORT,
392        }
393    }
394
395    /// What the loop guard does, reconciling [`Self::loop_guard_mode`] with
396    /// the deprecated [`Self::proxy_loop_detection`].
397    ///
398    /// The new setting wins outright when present. The boolean is consulted
399    /// only when it is absent, which [`Self::merge`] makes true of anything
400    /// this build has *written to a value* — an explicit clear of one spelling
401    /// leaves the other standing, so both can be absent and the default
402    /// answers: `Some(false)` is [`LoopGuardMode::Off`], and
403    /// `Some(true)` or absent is the default, [`LoopGuardMode::Note`]. An
404    /// explicit old "on" therefore becomes a note rather than a refusal,
405    /// which is the behaviour change #1052 exists to make.
406    ///
407    /// The one place this precedence is decided, so the proxy, the CLI and
408    /// anything that reports the setting cannot disagree about it.
409    #[must_use]
410    pub const fn effective_loop_guard_mode(&self) -> LoopGuardMode {
411        match (self.loop_guard_mode, self.proxy_loop_detection) {
412            (Some(mode), _) => mode,
413            (None, Some(false)) => LoopGuardMode::Off,
414            (None, _) => LoopGuardMode::Note,
415        }
416    }
417
418    /// Merge another settings into this one, only updating fields that are Some.
419    pub fn merge(&mut self, other: &SettingsUpdate) {
420        if let Some(ref path) = other.default_download_path {
421            self.default_download_path.clone_from(path);
422        }
423        if let Some(ref ctx_size) = other.default_context_size {
424            self.default_context_size = *ctx_size;
425        }
426        if let Some(ref port) = other.proxy_port {
427            self.proxy_port = *port;
428        }
429        if let Some(ref port) = other.llama_base_port {
430            self.llama_base_port = *port;
431        }
432        if let Some(ref queue_size) = other.max_download_queue_size {
433            self.max_download_queue_size = *queue_size;
434        }
435        if let Some(ref show_fit) = other.show_memory_fit_indicators {
436            self.show_memory_fit_indicators = *show_fit;
437        }
438        if let Some(ref iters) = other.max_tool_iterations {
439            self.max_tool_iterations = *iters;
440        }
441        if let Some(ref steps) = other.max_stagnation_steps {
442            self.max_stagnation_steps = *steps;
443        }
444        if let Some(ref model_id) = other.default_model_id {
445            self.default_model_id = *model_id;
446        }
447        if let Some(ref inference_defaults) = other.inference_defaults {
448            self.inference_defaults.clone_from(inference_defaults);
449        }
450        if let Some(ref inference_profiles) = other.inference_profiles {
451            self.inference_profiles.clone_from(inference_profiles);
452        }
453        if let Some(ref v) = other.setup_completed {
454            self.setup_completed = *v;
455        }
456        if let Some(ref v) = other.title_generation_prompt {
457            self.title_generation_prompt.clone_from(v);
458        }
459        if let Some(ref v) = other.bind_host {
460            self.bind_host.clone_from(v);
461        }
462        if let Some(ref v) = other.share_lan {
463            self.share_lan = *v;
464        }
465        if let Some(ref v) = other.proxy_api_key {
466            self.proxy_api_key.clone_from(v);
467        }
468        if let Some(ref v) = other.trust_client_sampling {
469            self.trust_client_sampling = *v;
470        }
471        if let Some(v) = other.tool_call_repair {
472            self.tool_call_repair = v;
473        }
474        // The loop guard's two spellings clear each other when one is
475        // *written to a value*, in this order, so they cannot disagree on
476        // disk and an update carrying both has one answer: the new setting's.
477        // An explicit null clears only itself — see below — so the pair can
478        // also end up both absent, which the default covers. That is what
479        // keeps `--proxy-loop-detection false` working for the release it is
480        // promised.
481        if let Some(ref v) = other.proxy_loop_detection {
482            self.proxy_loop_detection = *v;
483            // Only a *write* clears the other spelling. `Some(None)` is the
484            // "clear this field" update every `UpdateSettingsRequest` field
485            // must support, and clearing one spelling must not silently
486            // discard what the other says.
487            if v.is_some() {
488                self.loop_guard_mode = None;
489            }
490        }
491        if let Some(ref v) = other.loop_guard_mode {
492            self.loop_guard_mode = *v;
493            if v.is_some() {
494                self.proxy_loop_detection = None;
495            }
496        }
497        if let Some(ref v) = other.agentic_sampling {
498            self.agentic_sampling = *v;
499        }
500        if let Some(ref v) = other.proxy_autostart {
501            self.proxy_autostart = *v;
502        }
503        if let Some(ref v) = other.close_to_tray {
504            self.close_to_tray = *v;
505        }
506        if let Some(ref v) = other.start_at_login {
507            self.start_at_login = *v;
508        }
509        self.merge_remote(other);
510    }
511}
512
513/// Validate settings values.
514pub fn validate_settings(settings: &Settings) -> Result<(), SettingsError> {
515    // Validate context size
516    if let Some(ctx_size) = settings.default_context_size
517        && !CONTEXT_SIZE_RANGE.contains(&ctx_size)
518    {
519        return Err(SettingsError::InvalidContextSize(ctx_size));
520    }
521
522    // Validate proxy port
523    if let Some(port) = settings.proxy_port
524        && port < 1024
525    {
526        return Err(SettingsError::InvalidPort(port));
527    }
528
529    // Validate llama-server base port
530    if let Some(port) = settings.llama_base_port
531        && port < 1024
532    {
533        return Err(SettingsError::InvalidPort(port));
534    }
535
536    // Validate max download queue size
537    if let Some(queue_size) = settings.max_download_queue_size
538        && !(1..=50).contains(&queue_size)
539    {
540        return Err(SettingsError::InvalidQueueSize(queue_size));
541    }
542
543    // Validate download path if specified
544    if settings
545        .default_download_path
546        .as_ref()
547        .is_some_and(|p| p.trim().is_empty())
548    {
549        return Err(SettingsError::EmptyDownloadPath);
550    }
551
552    // Validate the bind host if specified. Requiring a literal IP (rather than
553    // accepting a name) keeps the value unambiguous for both the TCP bind and
554    // the mDNS address record.
555    if let Some(ref host) = settings.bind_host
556        && host.parse::<std::net::IpAddr>().is_err()
557    {
558        return Err(SettingsError::InvalidBindHost(host.clone()));
559    }
560
561    // A stored blank would read as "authentication is on" while accepting
562    // `Bearer ` from anyone. Clearing the field is the way to turn it off.
563    if settings
564        .proxy_api_key
565        .as_ref()
566        .is_some_and(|key| key.trim().is_empty())
567    {
568        return Err(SettingsError::BlankProxyApiKey);
569    }
570
571    settings_remote::validate_remote(settings)?;
572
573    // Validate inference defaults if specified
574    if let Some(ref inference_config) = settings.inference_defaults {
575        validate_inference_config(inference_config)
576            .map_err(SettingsError::InvalidInferenceConfig)?;
577    }
578
579    // Validate inference profiles if specified
580    if let Some(ref profiles) = settings.inference_profiles {
581        validate_inference_profiles(profiles).map_err(SettingsError::InvalidInferenceProfile)?;
582    }
583
584    Ok(())
585}
586
587#[cfg(test)]
588#[path = "settings_loop_guard_tests.rs"]
589mod settings_loop_guard_tests;
590
591#[cfg(test)]
592#[path = "settings_tests.rs"]
593mod settings_tests;
594
595#[cfg(test)]
596#[path = "settings_remote_tests.rs"]
597mod settings_remote_tests;