Skip to main content

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}