Skip to main content

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}