gglib_core/settings_validate.rs
1//! The inference-parameter validators, split from [`super`](crate::settings).
2//!
3//! Moved here whole, unchanged, so `settings.rs` could take the remote
4//! tunnel's two fields without growing past its ceiling. `validate_settings`
5//! stays beside the struct it validates and reaches these through the
6//! re-export; every external caller does too, so the module path is a detail.
7
8use crate::domain::{InferenceConfig, InferenceProfile};
9
10/// Validate a set of inference profiles.
11///
12/// Checks each profile's name against
13/// [`crate::domain::inference_profile::validate_name`], rejects
14/// duplicate names (they would make `{model}:{profile}` ambiguous), and reuses
15/// [`validate_inference_config`] for the numeric ranges so profile parameters
16/// and global defaults can never drift apart on what counts as valid.
17///
18/// # Errors
19///
20/// Returns a human-readable description of the first problem found.
21pub fn validate_inference_profiles(profiles: &[InferenceProfile]) -> Result<(), String> {
22 let mut seen: Vec<&str> = Vec::with_capacity(profiles.len());
23
24 for profile in profiles {
25 profile.validate().map_err(|e| e.to_string())?;
26
27 if seen.contains(&profile.name.as_str()) {
28 return Err(format!("duplicate profile name '{}'", profile.name));
29 }
30 seen.push(&profile.name);
31
32 validate_inference_config(&profile.config)
33 .map_err(|e| format!("profile '{}': {e}", profile.name))?;
34 }
35
36 Ok(())
37}
38
39/// Validate inference configuration parameters.
40///
41/// Checks that all specified parameters are within valid ranges.
42pub fn validate_inference_config(config: &InferenceConfig) -> Result<(), String> {
43 // Validate temperature (0.0 - 2.0)
44 if let Some(temp) = config.temperature
45 && !(0.0..=2.0).contains(&temp)
46 {
47 return Err(format!(
48 "Temperature must be between 0.0 and 2.0, got {temp}"
49 ));
50 }
51
52 // Validate top_p (0.0 - 1.0)
53 if let Some(top_p) = config.top_p
54 && !(0.0..=1.0).contains(&top_p)
55 {
56 return Err(format!("Top P must be between 0.0 and 1.0, got {top_p}"));
57 }
58
59 // Validate top_k (must be positive)
60 if let Some(top_k) = config.top_k
61 && top_k <= 0
62 {
63 return Err(format!("Top K must be positive, got {top_k}"));
64 }
65
66 // Validate max_tokens (must be positive)
67 if let Some(max_tokens) = config.max_tokens
68 && max_tokens == 0
69 {
70 return Err("Max tokens must be positive".to_string());
71 }
72
73 // Validate reasoning_budget_tokens (>= -1, exactly upstream's range —
74 // llama-server answers -2 with an HTTP 400 naming it, ADR 0007 finding 7c;
75 // -1 defers to the launch `--reasoning-budget` and 0 stops thinking).
76 //
77 // This guard is the *stored* half of a boundary the request half already
78 // has. `InferenceConfig::extract_client_sampling` applies the same range to
79 // a value that arrives on a request, but three surfaces deserialise a whole
80 // `InferenceConfig` and never pass through it: `Settings::inference_defaults`,
81 // `inference_profiles[].config`, and the proxy's `inference_override`. A
82 // value stored through any of them is force-inserted into every chat body,
83 // so `-5000` in global defaults means an HTTP 400 on every request to every
84 // model until someone finds the setting — and neither reasoning control is
85 // observable in `/slots` or `/props` (ADR 0007 finding 7a), so no readback
86 // can ever point at it. Rejecting at store time is the only place this is
87 // catchable.
88 //
89 // `reasoning_effort` needs no twin guard: it is an enum, so serde refuses
90 // an unknown level before this function is reached.
91 if let Some(budget) = config.reasoning_budget_tokens
92 && budget < -1
93 {
94 return Err(format!(
95 "Reasoning budget tokens must be -1 or greater \
96 (-1 defers to the launch default, 0 stops thinking), got {budget}"
97 ));
98 }
99
100 // Validate repeat_penalty (must be positive)
101 if let Some(repeat_penalty) = config.repeat_penalty
102 && repeat_penalty <= 0.0
103 {
104 return Err(format!(
105 "Repeat penalty must be positive, got {repeat_penalty}"
106 ));
107 }
108
109 // Validate presence_penalty (0.0 - 2.0)
110 if let Some(pp) = config.presence_penalty
111 && !(0.0..=2.0).contains(&pp)
112 {
113 return Err(format!(
114 "Presence penalty must be between 0.0 and 2.0, got {pp}"
115 ));
116 }
117
118 // Validate min_p (0.0 - 1.0)
119 if let Some(mp) = config.min_p
120 && !(0.0..=1.0).contains(&mp)
121 {
122 return Err(format!("Min P must be between 0.0 and 1.0, got {mp}"));
123 }
124
125 // Validate frequency_penalty (-2.0 - 2.0, the OpenAI-spec range llama.cpp
126 // honours; negative values encourage reuse and are valid upstream)
127 if let Some(fp) = config.frequency_penalty
128 && !(-2.0..=2.0).contains(&fp)
129 {
130 return Err(format!(
131 "Frequency penalty must be between -2.0 and 2.0, got {fp}"
132 ));
133 }
134
135 // Validate dynatemp_range (non-negative; 0.0 disables dynamic temperature)
136 if let Some(dr) = config.dynatemp_range
137 && dr < 0.0
138 {
139 return Err(format!(
140 "Dynatemp range must be non-negative (0.0 disables), got {dr}"
141 ));
142 }
143
144 // Validate dynatemp_exponent (must be positive; inert without a range)
145 if let Some(de) = config.dynatemp_exponent
146 && de <= 0.0
147 {
148 return Err(format!("Dynatemp exponent must be positive, got {de}"));
149 }
150
151 // Validate top_n_sigma (-1.0 disables; llama.cpp treats any value at or
152 // below zero as off, and -1.0 is its own spelling of the default)
153 if let Some(ts) = config.top_n_sigma
154 && ts < -1.0
155 {
156 return Err(format!(
157 "Top-n-sigma must be -1.0 (disabled) or greater, got {ts}"
158 ));
159 }
160
161 validate_dry_params(config)
162}
163
164/// The four DRY parameters' ranges, split out of [`validate_inference_config`].
165///
166/// Not a judgement about them — they are checked exactly as before and in the
167/// same order. They are simply the one cohesive group in a function that is
168/// otherwise one field per check, so lifting them is what kept the parent
169/// under `clippy::too_many_lines` when `reasoning_budget_tokens` joined. Every
170/// caller reaches this through the parent; nothing validates DRY alone.
171fn validate_dry_params(config: &InferenceConfig) -> Result<(), String> {
172 // Validate dry_multiplier (0.0 - 5.0; 0.0 disables DRY)
173 if let Some(dm) = config.dry_multiplier
174 && !(0.0..=5.0).contains(&dm)
175 {
176 return Err(format!(
177 "DRY multiplier must be between 0.0 and 5.0, got {dm}"
178 ));
179 }
180
181 // Validate dry_base (> 1.0; the exponent base grows the penalty with
182 // matched sequence length, so a base at or below 1.0 cannot penalise)
183 if let Some(db) = config.dry_base
184 && db <= 1.0
185 {
186 return Err(format!("DRY base must be greater than 1.0, got {db}"));
187 }
188
189 // Validate dry_allowed_length (non-negative token count)
190 if let Some(dal) = config.dry_allowed_length
191 && dal < 0
192 {
193 return Err(format!(
194 "DRY allowed length must be non-negative, got {dal}"
195 ));
196 }
197
198 // Validate dry_penalty_last_n (0 disables; negatives are resolved by
199 // llama.cpp against the context size)
200 if let Some(dpn) = config.dry_penalty_last_n
201 && dpn < -1
202 {
203 return Err(format!(
204 "DRY penalty last N must be -1 or greater (0 disables), got {dpn}"
205 ));
206 }
207
208 Ok(())
209}