Skip to main content

zeph_config/providers/
candle.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Candle local-inference backend configuration.
5//!
6//! Covers the `[llm.candle]` section ([`CandleConfig`]) and the inline per-provider
7//! variant ([`CandleInlineConfig`]) used inside `[[llm.providers]]`, plus the shared
8//! sampling parameters ([`GenerationParams`]) and device/source selectors.
9
10use serde::{Deserialize, Serialize};
11
12fn default_chat_template() -> String {
13    "chatml".into()
14}
15
16fn default_temperature() -> f64 {
17    0.7
18}
19
20fn default_max_tokens() -> usize {
21    2048
22}
23
24fn default_seed() -> u64 {
25    42
26}
27
28fn default_repeat_penalty() -> f32 {
29    1.1
30}
31
32fn default_repeat_last_n() -> usize {
33    64
34}
35/// Model source for the Candle local-inference backend.
36///
37/// Controls whether the model is downloaded from Hugging Face Hub or loaded
38/// from a local filesystem path specified in `local_path`.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
40#[serde(rename_all = "lowercase")]
41pub enum CandleSource {
42    /// Download model weights from Hugging Face Hub using the `repo_id` from
43    /// the provider's `model` field.
44    #[default]
45    Huggingface,
46    /// Load model weights from the local filesystem path in `local_path`.
47    Local,
48}
49
50/// Compute device for the Candle local-inference backend.
51///
52/// Determines which hardware accelerator is used for inference. Feature flags
53/// `candle/metal` and `candle/cuda` must be enabled at compile time for the
54/// corresponding variants to succeed at runtime.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
56#[serde(rename_all = "lowercase")]
57pub enum CandleDevice {
58    /// Run inference on the CPU.
59    #[default]
60    Cpu,
61    /// Run inference on an NVIDIA CUDA GPU (requires `cuda` feature).
62    Cuda,
63    /// Run inference on Apple Silicon via Metal (requires `metal` feature).
64    Metal,
65    /// Auto-detect the best available device: Metal → CUDA → CPU.
66    ///
67    /// Requires the corresponding `metal` or `cuda` feature to be enabled at compile time.
68    /// Falls back to CPU when no GPU features are compiled in.
69    Auto,
70}
71
72/// Configuration for the Candle local-inference backend.
73///
74/// Corresponds to the `[llm.candle]` section in `config.toml`. Used when the
75/// agent runs inference locally via `HuggingFace` Candle rather than a remote API.
76/// For inline provider definitions inside `[[llm.providers]]`, use
77/// [`CandleInlineConfig`] instead.
78#[derive(Deserialize, Serialize)]
79pub struct CandleConfig {
80    #[serde(default)]
81    pub source: CandleSource,
82    #[serde(default)]
83    pub local_path: String,
84    #[serde(default)]
85    pub filename: Option<String>,
86    #[serde(default = "default_chat_template")]
87    pub chat_template: String,
88    #[serde(default)]
89    pub device: CandleDevice,
90    #[serde(default)]
91    pub embedding_repo: Option<String>,
92    /// Resolved `HuggingFace` Hub API token for authenticated model downloads.
93    ///
94    /// Must be the **token value** — resolved by the caller before constructing this config.
95    #[serde(default)]
96    pub hf_token: Option<String>,
97    #[serde(default)]
98    pub generation: GenerationParams,
99    /// Maximum seconds to wait for each half of a single inference request.
100    ///
101    /// The timeout is applied **twice** per `chat()` call: once for the channel send
102    /// (waiting for a free slot) and once for the oneshot reply (waiting for the worker
103    /// to finish). The effective maximum wall-clock wait per request is therefore
104    /// `2 × inference_timeout_secs`. CPU inference can be slow; 120s is a conservative
105    /// default for large models, giving up to 240s total before an error is returned.
106    /// Values of 0 are silently promoted to 1 at bootstrap.
107    #[serde(default = "default_inference_timeout_secs")]
108    pub inference_timeout_secs: u64,
109}
110
111fn default_inference_timeout_secs() -> u64 {
112    120
113}
114
115impl std::fmt::Debug for CandleConfig {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.debug_struct("CandleConfig")
118            .field("source", &self.source)
119            .field("local_path", &self.local_path)
120            .field("filename", &self.filename)
121            .field("chat_template", &self.chat_template)
122            .field("device", &self.device)
123            .field("embedding_repo", &self.embedding_repo)
124            .field("hf_token", &self.hf_token.as_ref().map(|_| "[REDACTED]"))
125            .field("generation", &self.generation)
126            .field("inference_timeout_secs", &self.inference_timeout_secs)
127            .finish()
128    }
129}
130
131/// Sampling / generation parameters for Candle local inference.
132///
133/// Used inside `[llm.candle.generation]` or a `[[llm.providers]]` Candle entry.
134#[derive(Debug, Clone, Deserialize, Serialize)]
135pub struct GenerationParams {
136    /// Sampling temperature. Higher values produce more creative outputs. Default: `0.7`.
137    #[serde(default = "default_temperature")]
138    pub temperature: f64,
139    /// Nucleus sampling threshold. When set, tokens with cumulative probability above
140    /// this value are excluded. Default: `None` (disabled).
141    #[serde(default)]
142    pub top_p: Option<f64>,
143    /// Top-k sampling. When set, only the top-k most probable tokens are considered.
144    /// Default: `None` (disabled).
145    #[serde(default)]
146    pub top_k: Option<usize>,
147    /// Maximum number of tokens to generate per response. Capped at [`MAX_TOKENS_CAP`].
148    /// Default: `2048`.
149    #[serde(default = "default_max_tokens")]
150    pub max_tokens: usize,
151    /// Random seed for reproducible outputs. Default: `42`.
152    #[serde(default = "default_seed")]
153    pub seed: u64,
154    /// Repetition penalty applied during sampling. Default: `1.1`.
155    #[serde(default = "default_repeat_penalty")]
156    pub repeat_penalty: f32,
157    /// Number of last tokens to consider for the repetition penalty window. Default: `64`.
158    #[serde(default = "default_repeat_last_n")]
159    pub repeat_last_n: usize,
160}
161
162/// Hard upper bound on `GenerationParams::max_tokens` to prevent unbounded generation.
163pub const MAX_TOKENS_CAP: usize = 32768;
164
165impl GenerationParams {
166    /// Returns `max_tokens` clamped to [`MAX_TOKENS_CAP`].
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// use zeph_config::GenerationParams;
172    ///
173    /// let params = GenerationParams::default();
174    /// assert!(params.capped_max_tokens() <= 32768);
175    /// ```
176    #[must_use]
177    pub fn capped_max_tokens(&self) -> usize {
178        self.max_tokens.min(MAX_TOKENS_CAP)
179    }
180}
181
182impl Default for GenerationParams {
183    fn default() -> Self {
184        Self {
185            temperature: default_temperature(),
186            top_p: None,
187            top_k: None,
188            max_tokens: default_max_tokens(),
189            seed: default_seed(),
190            repeat_penalty: default_repeat_penalty(),
191            repeat_last_n: default_repeat_last_n(),
192        }
193    }
194}
195/// Inline candle config for use inside `ProviderEntry`.
196/// Re-uses the generation params from `CandleConfig`.
197#[derive(Clone, Deserialize, Serialize)]
198pub struct CandleInlineConfig {
199    #[serde(default)]
200    pub source: CandleSource,
201    #[serde(default)]
202    pub local_path: String,
203    #[serde(default)]
204    pub filename: Option<String>,
205    /// Optional SHA-256 hex digest of the chat model file (GGUF).
206    ///
207    /// When set, the file is verified before loading. Mismatch aborts startup with an error.
208    /// Useful for security-sensitive deployments to detect corruption or tampering.
209    #[serde(default)]
210    pub chat_model_sha256: Option<String>,
211    #[serde(default = "default_chat_template")]
212    pub chat_template: String,
213    #[serde(default)]
214    pub device: CandleDevice,
215    #[serde(default)]
216    pub embedding_repo: Option<String>,
217    /// Optional SHA-256 hex digest of the embedding model safetensors file.
218    ///
219    /// When set, the file is verified before loading. Mismatch aborts startup with an error.
220    #[serde(default)]
221    pub embedding_model_sha256: Option<String>,
222    /// Resolved `HuggingFace` Hub API token for authenticated model downloads.
223    #[serde(default)]
224    pub hf_token: Option<String>,
225    #[serde(default)]
226    pub generation: GenerationParams,
227    /// Maximum wall-clock seconds to wait for a single inference request.
228    ///
229    /// Effective timeout is `2 × inference_timeout_secs` (send + recv each have this budget).
230    /// CPU inference can be slow; 120s is a conservative default. Floored at 1s.
231    #[serde(default = "default_inference_timeout_secs")]
232    pub inference_timeout_secs: u64,
233}
234
235impl Default for CandleInlineConfig {
236    fn default() -> Self {
237        Self {
238            source: CandleSource::default(),
239            local_path: String::new(),
240            filename: None,
241            chat_model_sha256: None,
242            chat_template: default_chat_template(),
243            device: CandleDevice::default(),
244            embedding_repo: None,
245            embedding_model_sha256: None,
246            hf_token: None,
247            generation: GenerationParams::default(),
248            inference_timeout_secs: default_inference_timeout_secs(),
249        }
250    }
251}
252
253impl std::fmt::Debug for CandleInlineConfig {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        f.debug_struct("CandleInlineConfig")
256            .field("source", &self.source)
257            .field("local_path", &self.local_path)
258            .field("filename", &self.filename)
259            .field("chat_model_sha256", &self.chat_model_sha256)
260            .field("chat_template", &self.chat_template)
261            .field("device", &self.device)
262            .field("embedding_repo", &self.embedding_repo)
263            .field("embedding_model_sha256", &self.embedding_model_sha256)
264            .field("hf_token", &self.hf_token.as_ref().map(|_| "[REDACTED]"))
265            .field("generation", &self.generation)
266            .field("inference_timeout_secs", &self.inference_timeout_secs)
267            .finish()
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn candle_config_debug_redacts_hf_token() {
277        let cfg = CandleConfig {
278            source: CandleSource::default(),
279            local_path: String::new(),
280            filename: None,
281            chat_template: default_chat_template(),
282            device: CandleDevice::default(),
283            embedding_repo: None,
284            hf_token: Some("hf_SUPERSECRET".to_owned()),
285            generation: GenerationParams::default(),
286            inference_timeout_secs: default_inference_timeout_secs(),
287        };
288        let dbg = format!("{cfg:?}");
289        assert!(!dbg.contains("hf_SUPERSECRET"));
290        assert!(dbg.contains("[REDACTED]"));
291    }
292
293    #[test]
294    fn candle_config_debug_none_hf_token() {
295        let cfg = CandleConfig {
296            source: CandleSource::default(),
297            local_path: String::new(),
298            filename: None,
299            chat_template: default_chat_template(),
300            device: CandleDevice::default(),
301            embedding_repo: None,
302            hf_token: None,
303            generation: GenerationParams::default(),
304            inference_timeout_secs: default_inference_timeout_secs(),
305        };
306        let dbg = format!("{cfg:?}");
307        assert!(!dbg.contains("[REDACTED]"));
308        assert!(dbg.contains("hf_token: None"));
309    }
310
311    #[test]
312    fn candle_inline_config_debug_redacts_hf_token() {
313        let cfg = CandleInlineConfig {
314            hf_token: Some("hf_SUPERSECRET".to_owned()),
315            ..CandleInlineConfig::default()
316        };
317        let dbg = format!("{cfg:?}");
318        assert!(!dbg.contains("hf_SUPERSECRET"));
319        assert!(dbg.contains("[REDACTED]"));
320    }
321
322    #[test]
323    fn candle_inline_config_debug_none_hf_token() {
324        let cfg = CandleInlineConfig::default();
325        let dbg = format!("{cfg:?}");
326        assert!(!dbg.contains("[REDACTED]"));
327        assert!(dbg.contains("hf_token: None"));
328    }
329}