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;