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, ReasoningEffort};
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#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
73#[serde(rename_all = "camelCase")]
74pub struct InferenceProfile {
75 /// Profile slug, used as the `:{suffix}` on a model id.
76 ///
77 /// Constrained by [`validate_name`] to lowercase alphanumerics and `-`.
78 pub name: String,
79
80 /// Human-readable summary, surfaced in `/v1/models` and the settings UI.
81 pub description: Option<String>,
82
83 /// The sampling overrides. Sparse — see the module docs.
84 pub config: InferenceConfig,
85
86 /// Whether to advertise `{model}:{name}` as its own `/v1/models` entry.
87 ///
88 /// Off by default: with several models and several profiles the full cross
89 /// product would swamp a client's model picker. Users opt in for the one or
90 /// two profiles they switch between often; the rest stay addressable by
91 /// name without appearing in the list.
92 pub list_in_models: bool,
93}
94
95/// Validate a profile name.
96///
97/// The accepted set — lowercase alphanumerics and `-`, 1–[`MAX_PROFILE_NAME_LEN`]
98/// characters, no leading or trailing `-` — is deliberately narrower than what
99/// most clients accept. Ollama-style `name:tag` ids prove that colons and
100/// hyphens are safe in OpenAI-compatible frontends, but there are field reports
101/// of ids containing underscores being rejected where the same id without one
102/// worked. This set is the conservative intersection.
103///
104/// # Errors
105///
106/// Returns the specific [`ProfileNameError`] describing the first rule violated.
107pub fn validate_name(name: &str) -> Result<(), ProfileNameError> {
108 if name.is_empty() {
109 return Err(ProfileNameError::Empty);
110 }
111 if name.len() > MAX_PROFILE_NAME_LEN {
112 return Err(ProfileNameError::TooLong(name.len()));
113 }
114 if !name
115 .chars()
116 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
117 {
118 return Err(ProfileNameError::InvalidCharacters(name.to_owned()));
119 }
120 if name.starts_with('-') || name.ends_with('-') {
121 return Err(ProfileNameError::HyphenBoundary(name.to_owned()));
122 }
123 if RESERVED_PROFILE_NAMES.contains(&name) {
124 return Err(ProfileNameError::Reserved(name.to_owned()));
125 }
126 Ok(())
127}
128
129impl InferenceProfile {
130 /// Validate this profile's name.
131 ///
132 /// # Errors
133 ///
134 /// Propagates [`validate_name`].
135 pub fn validate(&self) -> Result<(), ProfileNameError> {
136 validate_name(&self.name)
137 }
138}
139
140/// Starting-point profiles a user can install and then edit.
141///
142/// These are *templates*, not behaviour: nothing reads them at request time and
143/// installing them simply seeds the user's own profile list. Each sets only the
144/// parameters that actually characterise its use case, leaving everything else
145/// to fall through to the model's own defaults.
146///
147/// Two families, kept in separate functions because they are separate
148/// arguments: [`sampling_templates`] picks a distribution, and
149/// [`reasoning_templates`] picks how hard the model is asked to think.
150#[must_use]
151pub fn builtin_templates() -> Vec<InferenceProfile> {
152 let mut templates = sampling_templates();
153 templates.extend(reasoning_templates());
154 templates
155}
156
157/// The distribution-shaping templates: temperature and `top_p` only.
158///
159/// `chat` is the only one listed in `/v1/models` out of the box — it is the
160/// conversational-client case that motivates the feature, and one visible
161/// variant keeps the model picker useful without swamping it.
162fn sampling_templates() -> Vec<InferenceProfile> {
163 vec![
164 InferenceProfile {
165 name: "coding".to_owned(),
166 description: Some("Low-variance sampling for code generation and tool use.".to_owned()),
167 config: InferenceConfig {
168 temperature: Some(0.2),
169 top_p: Some(0.9),
170 ..Default::default()
171 },
172 list_in_models: false,
173 },
174 InferenceProfile {
175 name: "chat".to_owned(),
176 description: Some("Balanced sampling for conversational use.".to_owned()),
177 config: InferenceConfig {
178 temperature: Some(0.7),
179 top_p: Some(0.95),
180 ..Default::default()
181 },
182 list_in_models: true,
183 },
184 InferenceProfile {
185 name: "creative".to_owned(),
186 description: Some("Wider sampling for brainstorming and prose.".to_owned()),
187 config: InferenceConfig {
188 temperature: Some(1.1),
189 top_p: Some(0.98),
190 ..Default::default()
191 },
192 list_in_models: false,
193 },
194 ]
195}
196
197/// One template per rung of the [`ReasoningEffort`] ladder.
198///
199/// # Why each rung sets *both* controls
200///
201/// [`reasoning_effort`] is a string a chat template may read at render time —
202/// and may equally ignore, in perfect silence (ADR 0007 finding 3). A profile
203/// that carried only the effort level would therefore do *nothing at all* on
204/// such a model, while reading in `gglib config profile show` as though it
205/// had. Pairing it with [`reasoning_budget_tokens`] — which llama.cpp itself
206/// enforces, whatever the template does — means the rung degrades to a
207/// narrower promise rather than to no promise: on a template that reads the
208/// variable the user gets both, and on one that does not they still get a
209/// thinking cap they chose.
210///
211/// # The budget ladder, and why these numbers
212///
213/// | profile | effort | budget | what the budget is for |
214/// |---------|--------|--------|------------------------|
215/// | `minimal` | `minimal` | 256 | a sentence or two of scratch work — an answer, not a deliberation |
216/// | `low` | `low` | 1024 | one short chain; enough to check an assumption |
217/// | `medium` | `medium` | 4096 | the middle rung, and roughly what an untouched `gpt-oss` turn spends |
218/// | `high` | `high` | 16384 | multi-step work where the thinking is the point |
219/// | `xhigh` | `xhigh` | 32768 | long deliberation, still bounded so a loop terminates |
220/// | `max` | `max` | -1 | defer to the launch-time `--reasoning-budget` |
221///
222/// Roughly a quadrupling per rung to 16384 and a doubling after, because the
223/// levels are not linear either: nothing in llama.cpp compares them and a
224/// template is free to treat two of them identically, so the ladder is spaced
225/// widely enough that adjacent rungs are distinguishable in practice rather
226/// than finely enough to imply a precision that does not exist. Nothing is
227/// measured here — these are *starting points a user edits*, and the one
228/// number that is not a guess is `max`'s `-1`, which declines to invent a
229/// ceiling and leaves the operator's own launch default in charge.
230///
231/// # Only three are listed
232///
233/// Six listed variants per model would swamp the very model picker
234/// [`InferenceProfile::list_in_models`] exists to protect, so `low`, `high`
235/// and `max` — the ends and a usable middle — are the visible ones. The other
236/// three stay fully usable by name as `<model>:minimal` and friends.
237///
238/// [`ReasoningEffort`]: crate::domain::ReasoningEffort
239/// [`reasoning_effort`]: InferenceConfig::reasoning_effort
240/// [`reasoning_budget_tokens`]: InferenceConfig::reasoning_budget_tokens
241fn reasoning_templates() -> Vec<InferenceProfile> {
242 /// `(name, effort, budget, listed)` — one row per rung, weakest first.
243 const LADDER: [(&str, ReasoningEffort, i32, bool); 6] = [
244 ("minimal", ReasoningEffort::Minimal, 256, false),
245 ("low", ReasoningEffort::Low, 1024, true),
246 ("medium", ReasoningEffort::Medium, 4096, false),
247 ("high", ReasoningEffort::High, 16384, true),
248 ("xhigh", ReasoningEffort::XHigh, 32768, false),
249 ("max", ReasoningEffort::Max, -1, true),
250 ];
251
252 LADDER
253 .into_iter()
254 .map(|(name, effort, budget, listed)| InferenceProfile {
255 name: name.to_owned(),
256 description: Some(describe_rung(effort, budget)),
257 config: InferenceConfig {
258 reasoning_effort: Some(effort),
259 reasoning_budget_tokens: Some(budget),
260 ..Default::default()
261 },
262 list_in_models: listed,
263 })
264 .collect()
265}
266
267/// The description shown in `/v1/models` and the settings UI for one rung.
268///
269/// Spells out both halves, including the fact that the effort half is only a
270/// request: a user reading the list should not have to know ADR 0007 to learn
271/// that a template may ignore it.
272fn describe_rung(effort: ReasoningEffort, budget: i32) -> String {
273 let cap = if budget < 0 {
274 "no gglib-set cap (defers to the launch default)".to_owned()
275 } else {
276 format!("at most {budget} thinking tokens")
277 };
278 format!("Asks for '{effort}' reasoning effort where the template reads it; {cap}.")
279}
280
281#[cfg(test)]
282#[path = "inference_profile_tests.rs"]
283mod inference_profile_tests;