gglib_core/domain/inference_profile.rs
1//! Named, cross-model sampling profiles.
2//!
3//! A profile is a *named, sparse* [`InferenceConfig`] that a client selects per
4//! request by appending `:{name}` to the model it asks for — `qwen3.6:coding`.
5//! It exists because a single gglib proxy serves clients with incompatible
6//! sampling needs: coding agents want low-temperature determinism while
7//! conversational UIs want something warmer. Both hit the same model name, so
8//! per-model `inference_defaults` alone cannot tell them apart.
9//!
10//! The `{name}:{variant}` shape follows Ollama's universal `name:tag`
11//! convention, which is what makes the variants render and select correctly in
12//! OpenAI-compatible clients like `OpenWebUI`.
13//!
14//! # Profiles are sparse
15//!
16//! Only the fields a profile explicitly sets are `Some`; the rest stay `None`
17//! and fall through to the layers below (per-model defaults, then global
18//! settings, then the hardcoded fallback). This is what makes one global
19//! profile safe to apply across heterogeneous model architectures: a `coding`
20//! profile that sets only `temperature` and `top_p` still lets a thinking model
21//! contribute its own `presence_penalty` from
22//! [`InferenceConfig::reasoning_profile`]. A profile that carried a value for
23//! every field would silently erase per-model tuning that exists for good
24//! architectural reasons.
25//!
26//! See [`InferenceConfig::resolve_with_profile`] for the full merge order.
27
28use serde::{Deserialize, Serialize};
29
30use crate::domain::InferenceConfig;
31
32/// Maximum length of a profile name.
33///
34/// Deliberately short. Profile names become part of the model id advertised to
35/// clients (`{model}:{profile}`), and long ids are one of the reported causes
36/// of model-id rejection in OpenAI-compatible frontends.
37pub const MAX_PROFILE_NAME_LEN: usize = 32;
38
39/// Names that cannot be used for a profile because they already mean something
40/// as a `:{suffix}` on a model id.
41///
42/// The list is held over from the removed council virtual models, whose
43/// `:interactive` and `:native` suffixes the proxy matched whole. Nothing
44/// claims these suffixes today, so the guard is a namespace reservation
45/// rather than a correctness requirement — kept because it is user-visible
46/// (the settings editor rejects them by name) and costs nothing.
47pub const RESERVED_PROFILE_NAMES: &[&str] = &["interactive", "native"];
48
49/// Why a profile name was rejected.
50#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
51pub enum ProfileNameError {
52 #[error("profile name cannot be empty")]
53 Empty,
54
55 #[error("profile name is {0} characters; the maximum is {MAX_PROFILE_NAME_LEN}")]
56 TooLong(usize),
57
58 #[error(
59 "profile name '{0}' contains invalid characters; use lowercase letters, digits, and '-'"
60 )]
61 InvalidCharacters(String),
62
63 #[error("profile name '{0}' cannot start or end with '-'")]
64 HyphenBoundary(String),
65
66 #[error("profile name '{0}' is reserved")]
67 Reserved(String),
68}
69
70/// A named sampling profile applied on top of a model's own defaults.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72#[serde(rename_all = "camelCase")]
73pub struct InferenceProfile {
74 /// Profile slug, used as the `:{suffix}` on a model id.
75 ///
76 /// Constrained by [`validate_name`] to lowercase alphanumerics and `-`.
77 pub name: String,
78
79 /// Human-readable summary, surfaced in `/v1/models` and the settings UI.
80 pub description: Option<String>,
81
82 /// The sampling overrides. Sparse — see the module docs.
83 pub config: InferenceConfig,
84
85 /// Whether to advertise `{model}:{name}` as its own `/v1/models` entry.
86 ///
87 /// Off by default: with several models and several profiles the full cross
88 /// product would swamp a client's model picker. Users opt in for the one or
89 /// two profiles they switch between often; the rest stay addressable by
90 /// name without appearing in the list.
91 pub list_in_models: bool,
92}
93
94/// Validate a profile name.
95///
96/// The accepted set — lowercase alphanumerics and `-`, 1–[`MAX_PROFILE_NAME_LEN`]
97/// characters, no leading or trailing `-` — is deliberately narrower than what
98/// most clients accept. Ollama-style `name:tag` ids prove that colons and
99/// hyphens are safe in OpenAI-compatible frontends, but there are field reports
100/// of ids containing underscores being rejected where the same id without one
101/// worked. This set is the conservative intersection.
102///
103/// # Errors
104///
105/// Returns the specific [`ProfileNameError`] describing the first rule violated.
106pub fn validate_name(name: &str) -> Result<(), ProfileNameError> {
107 if name.is_empty() {
108 return Err(ProfileNameError::Empty);
109 }
110 if name.len() > MAX_PROFILE_NAME_LEN {
111 return Err(ProfileNameError::TooLong(name.len()));
112 }
113 if !name
114 .chars()
115 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
116 {
117 return Err(ProfileNameError::InvalidCharacters(name.to_owned()));
118 }
119 if name.starts_with('-') || name.ends_with('-') {
120 return Err(ProfileNameError::HyphenBoundary(name.to_owned()));
121 }
122 if RESERVED_PROFILE_NAMES.contains(&name) {
123 return Err(ProfileNameError::Reserved(name.to_owned()));
124 }
125 Ok(())
126}
127
128impl InferenceProfile {
129 /// Validate this profile's name.
130 ///
131 /// # Errors
132 ///
133 /// Propagates [`validate_name`].
134 pub fn validate(&self) -> Result<(), ProfileNameError> {
135 validate_name(&self.name)
136 }
137}
138
139/// Starting-point profiles a user can install and then edit.
140///
141/// These are *templates*, not behaviour: nothing reads them at request time and
142/// installing them simply seeds the user's own profile list. Each sets only the
143/// two parameters that actually characterise its use case, leaving everything
144/// else to fall through to the model's own defaults.
145///
146/// `chat` is the only one listed in `/v1/models` out of the box — it is the
147/// conversational-client case that motivates the feature, and one visible
148/// variant keeps the model picker useful without swamping it.
149#[must_use]
150pub fn builtin_templates() -> Vec<InferenceProfile> {
151 vec![
152 InferenceProfile {
153 name: "coding".to_owned(),
154 description: Some("Low-variance sampling for code generation and tool use.".to_owned()),
155 config: InferenceConfig {
156 temperature: Some(0.2),
157 top_p: Some(0.9),
158 ..Default::default()
159 },
160 list_in_models: false,
161 },
162 InferenceProfile {
163 name: "chat".to_owned(),
164 description: Some("Balanced sampling for conversational use.".to_owned()),
165 config: InferenceConfig {
166 temperature: Some(0.7),
167 top_p: Some(0.95),
168 ..Default::default()
169 },
170 list_in_models: true,
171 },
172 InferenceProfile {
173 name: "creative".to_owned(),
174 description: Some("Wider sampling for brainstorming and prose.".to_owned()),
175 config: InferenceConfig {
176 temperature: Some(1.1),
177 top_p: Some(0.98),
178 ..Default::default()
179 },
180 list_in_models: false,
181 },
182 ]
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn accepts_lowercase_alphanumeric_and_hyphen() {
191 for name in ["coding", "chat", "creative", "long-form", "gpt4-style", "a"] {
192 assert!(validate_name(name).is_ok(), "should accept {name}");
193 }
194 }
195
196 #[test]
197 fn rejects_empty_name() {
198 assert_eq!(validate_name(""), Err(ProfileNameError::Empty));
199 }
200
201 #[test]
202 fn rejects_name_over_the_length_cap() {
203 let long = "a".repeat(MAX_PROFILE_NAME_LEN + 1);
204 assert_eq!(
205 validate_name(&long),
206 Err(ProfileNameError::TooLong(MAX_PROFILE_NAME_LEN + 1))
207 );
208 assert!(validate_name(&"a".repeat(MAX_PROFILE_NAME_LEN)).is_ok());
209 }
210
211 /// Uppercase, underscores, dots, spaces and colons are all outside the
212 /// conservative set — the colon especially, since it is the delimiter.
213 #[test]
214 fn rejects_characters_outside_the_conservative_set() {
215 for name in ["Coding", "long_form", "v1.2", "long form", "a:b", "café"] {
216 assert!(
217 matches!(
218 validate_name(name),
219 Err(ProfileNameError::InvalidCharacters(_))
220 ),
221 "should reject {name}"
222 );
223 }
224 }
225
226 #[test]
227 fn rejects_leading_or_trailing_hyphen() {
228 for name in ["-coding", "coding-", "-"] {
229 assert!(
230 matches!(
231 validate_name(name),
232 Err(ProfileNameError::HyphenBoundary(_))
233 ),
234 "should reject {name}"
235 );
236 }
237 }
238
239 #[test]
240 fn rejects_reserved_profile_names() {
241 for name in RESERVED_PROFILE_NAMES {
242 assert_eq!(
243 validate_name(name),
244 Err(ProfileNameError::Reserved((*name).to_owned()))
245 );
246 }
247 }
248
249 #[test]
250 fn builtin_template_names_are_valid_and_unique() {
251 let templates = builtin_templates();
252 let mut names: Vec<&str> = templates.iter().map(|p| p.name.as_str()).collect();
253 names.sort_unstable();
254 let unique_count = {
255 let mut deduped = names.clone();
256 deduped.dedup();
257 deduped.len()
258 };
259 assert_eq!(names.len(), unique_count, "template names must be unique");
260
261 for profile in &templates {
262 assert!(profile.validate().is_ok(), "invalid name: {}", profile.name);
263 }
264 }
265
266 /// The central invariant: a template must leave the parameters it does not
267 /// care about as `None` so they still resolve from the model's own
268 /// defaults. A template that filled every field would silently override
269 /// per-model tuning such as `reasoning_profile`'s `presence_penalty`.
270 #[test]
271 fn builtin_templates_are_sparse() {
272 for profile in builtin_templates() {
273 let c = &profile.config;
274 assert!(c.temperature.is_some(), "{} sets temperature", profile.name);
275 assert!(c.top_p.is_some(), "{} sets top_p", profile.name);
276 assert!(c.top_k.is_none(), "{} leaves top_k open", profile.name);
277 assert!(
278 c.max_tokens.is_none(),
279 "{} leaves max_tokens open",
280 profile.name
281 );
282 assert!(
283 c.repeat_penalty.is_none(),
284 "{} leaves repeat_penalty open",
285 profile.name
286 );
287 assert!(
288 c.presence_penalty.is_none(),
289 "{} leaves presence_penalty open",
290 profile.name
291 );
292 assert!(c.min_p.is_none(), "{} leaves min_p open", profile.name);
293 }
294 }
295
296 #[test]
297 fn serializes_with_camel_case_keys() {
298 let profile = &builtin_templates()[0];
299 let json = serde_json::to_value(profile).expect("serializes");
300 assert!(json.get("listInModels").is_some());
301 assert!(json.get("list_in_models").is_none());
302 }
303}