Skip to main content

zeph_config/providers/
llm.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Core LLM configuration: the `[llm]` section, provider-kind selector, streaming
5//! limits, and speech-to-text config.
6//!
7//! [`LlmConfig`] is the root of the LLM subsystem config; it owns the provider pool
8//! ([`super::ProviderEntry`]) and references the routing, candle, and STT config types.
9
10use serde::{Deserialize, Serialize};
11use zeph_common::ProviderName;
12
13use super::{
14    CandleConfig, CoeConfig, ComplexityRoutingConfig, LlmRoutingStrategy, ProviderEntry,
15    RouterConfig,
16};
17
18fn default_response_cache_ttl_secs() -> u64 {
19    3600
20}
21
22fn default_semantic_cache_threshold() -> f32 {
23    0.95
24}
25
26fn default_semantic_cache_max_candidates() -> u32 {
27    10
28}
29
30fn default_router_ema_alpha() -> f64 {
31    0.1
32}
33
34fn default_router_reorder_interval() -> u64 {
35    10
36}
37
38fn default_embedding_model() -> String {
39    "qwen3-embedding".into()
40}
41/// Returns the default STT transcription language hint (`"auto"`).
42#[must_use]
43pub fn default_stt_language() -> String {
44    "auto".into()
45}
46
47/// Returns the default embedding model name used by `[llm] embedding_model`.
48#[must_use]
49pub(crate) fn get_default_embedding_model() -> String {
50    default_embedding_model()
51}
52
53/// Returns the default response cache TTL in seconds.
54#[must_use]
55pub(crate) fn get_default_response_cache_ttl_secs() -> u64 {
56    default_response_cache_ttl_secs()
57}
58
59/// Returns the default EMA alpha for the router latency estimator.
60#[must_use]
61pub(crate) fn get_default_router_ema_alpha() -> f64 {
62    default_router_ema_alpha()
63}
64
65/// Returns the default router reorder interval (turns between provider re-ranking).
66#[must_use]
67pub(crate) fn get_default_router_reorder_interval() -> u64 {
68    default_router_reorder_interval()
69}
70
71/// LLM provider backend selector.
72///
73/// Used in `[[llm.providers]]` entries as the `type` field.
74///
75/// # Example (TOML)
76///
77/// ```toml
78/// [[llm.providers]]
79/// type = "openai"
80/// model = "gpt-4o"
81/// name = "quality"
82/// ```
83#[non_exhaustive]
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
85#[serde(rename_all = "lowercase")]
86pub enum ProviderKind {
87    /// Local Ollama server (default base URL: `http://localhost:11434`).
88    Ollama,
89    /// Anthropic Claude API.
90    Claude,
91    /// `OpenAI` API.
92    OpenAi,
93    /// Google Gemini API.
94    Gemini,
95    /// Local Candle inference (CPU/GPU, no external server required).
96    Candle,
97    /// OpenAI-compatible third-party API (e.g. Groq, Together AI, LM Studio).
98    Compatible,
99    /// Native Gonka blockchain provider.
100    Gonka,
101    /// Cocoon confidential compute network via localhost sidecar.
102    Cocoon,
103}
104
105impl ProviderKind {
106    /// Return the lowercase string identifier for this provider kind.
107    ///
108    /// # Examples
109    ///
110    /// ```
111    /// use zeph_config::ProviderKind;
112    ///
113    /// assert_eq!(ProviderKind::Claude.as_str(), "claude");
114    /// assert_eq!(ProviderKind::OpenAi.as_str(), "openai");
115    /// ```
116    #[must_use]
117    pub fn as_str(self) -> &'static str {
118        match self {
119            Self::Ollama => "ollama",
120            Self::Claude => "claude",
121            Self::OpenAi => "openai",
122            Self::Gemini => "gemini",
123            Self::Candle => "candle",
124            Self::Compatible => "compatible",
125            Self::Gonka => "gonka",
126            Self::Cocoon => "cocoon",
127        }
128    }
129}
130
131impl std::fmt::Display for ProviderKind {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.pad(self.as_str())
134    }
135}
136
137fn default_max_tool_json_bytes() -> usize {
138    4 * 1024 * 1024
139}
140
141fn default_max_thinking_bytes() -> usize {
142    1024 * 1024
143}
144
145fn default_max_compaction_bytes() -> usize {
146    32 * 1024
147}
148
149fn stream_limits_is_default(v: &StreamLimits) -> bool {
150    v.max_tool_json_bytes == default_max_tool_json_bytes()
151        && v.max_thinking_bytes == default_max_thinking_bytes()
152        && v.max_compaction_bytes == default_max_compaction_bytes()
153}
154
155/// Per-buffer byte caps for Claude SSE streaming.
156///
157/// Controls the maximum number of bytes accumulated in each streaming buffer before
158/// excess data is discarded with a warning. All caps default to values that match the
159/// pre-existing hardcoded constants, so omitting `[llm.stream_limits]` in the config
160/// preserves identical behavior.
161///
162/// # Example (TOML)
163///
164/// ```toml
165/// [llm.stream_limits]
166/// max_tool_json_bytes  = 8388608   # 8 MiB  — raise for unusually large tool results
167/// max_thinking_bytes   = 2097152   # 2 MiB  — raise for deep extended-thinking runs
168/// max_compaction_bytes = 65536     # 64 KiB — raise for verbose compaction summaries
169/// ```
170#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
171pub struct StreamLimits {
172    /// Maximum bytes for an accumulated tool-use JSON buffer. Default: 4 MiB.
173    #[serde(default = "default_max_tool_json_bytes")]
174    pub max_tool_json_bytes: usize,
175
176    /// Maximum bytes for an accumulated thinking block. Default: 1 MiB.
177    #[serde(default = "default_max_thinking_bytes")]
178    pub max_thinking_bytes: usize,
179
180    /// Maximum bytes for an accumulated server-side compaction summary. Default: 32 KiB.
181    #[serde(default = "default_max_compaction_bytes")]
182    pub max_compaction_bytes: usize,
183}
184
185impl Default for StreamLimits {
186    fn default() -> Self {
187        Self {
188            max_tool_json_bytes: default_max_tool_json_bytes(),
189            max_thinking_bytes: default_max_thinking_bytes(),
190            max_compaction_bytes: default_max_compaction_bytes(),
191        }
192    }
193}
194
195/// LLM configuration, nested under `[llm]` in TOML.
196///
197/// Declares the provider pool and controls routing, embedding, caching, and STT.
198/// All providers are declared in `[[llm.providers]]`; subsystems reference them by
199/// the `name` field using a `*_provider` config key.
200///
201/// # Example (TOML)
202///
203/// ```toml
204/// [[llm.providers]]
205/// name = "fast"
206/// type = "openai"
207/// model = "gpt-4o-mini"
208///
209/// [[llm.providers]]
210/// name = "quality"
211/// type = "claude"
212/// model = "claude-opus-4-5"
213///
214/// [llm]
215/// routing = "none"
216/// embedding_model = "qwen3-embedding"
217/// ```
218#[derive(Debug, Deserialize, Serialize)]
219pub struct LlmConfig {
220    /// Provider pool. First entry is default unless one is marked `default = true`.
221    #[serde(default, skip_serializing_if = "Vec::is_empty")]
222    pub providers: Vec<ProviderEntry>,
223
224    /// Routing strategy for multi-provider configs.
225    #[serde(default, skip_serializing_if = "is_routing_none")]
226    pub routing: LlmRoutingStrategy,
227
228    #[serde(default = "default_embedding_model_opt")]
229    pub embedding_model: String,
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub candle: Option<CandleConfig>,
232    #[serde(default)]
233    pub stt: Option<SttConfig>,
234    #[serde(default)]
235    pub response_cache_enabled: bool,
236    #[serde(default = "default_response_cache_ttl_secs")]
237    pub response_cache_ttl_secs: u64,
238    /// Enable semantic similarity-based response caching. Requires embedding support.
239    #[serde(default)]
240    pub semantic_cache_enabled: bool,
241    /// Cosine similarity threshold for semantic cache hits (0.0–1.0).
242    ///
243    /// Only the highest-scoring candidate above this threshold is returned.
244    /// Lower values produce more cache hits but risk returning less relevant responses.
245    /// Recommended range: 0.92–0.98; default: 0.95.
246    #[serde(default = "default_semantic_cache_threshold")]
247    pub semantic_cache_threshold: f32,
248    /// Maximum cached entries to examine per semantic lookup (SQL `LIMIT` clause in
249    /// `ResponseCache::get_semantic()`). Controls the recall-vs-performance tradeoff:
250    ///
251    /// - **Higher values** (e.g. 50): scan more entries, better chance of finding a
252    ///   semantically similar cached response, but slower queries.
253    /// - **Lower values** (e.g. 5): faster queries, but may miss relevant cached entries
254    ///   when the cache is large.
255    /// - **Default (10)**: balanced middle ground for typical workloads.
256    ///
257    /// Tuning guidance: set to 50+ when recall matters more than latency (e.g. long-running
258    /// sessions with many cached responses); reduce to 5 for low-latency interactive use.
259    /// Env override: `ZEPH_LLM_SEMANTIC_CACHE_MAX_CANDIDATES`.
260    #[serde(default = "default_semantic_cache_max_candidates")]
261    pub semantic_cache_max_candidates: u32,
262    #[serde(default)]
263    pub router_ema_enabled: bool,
264    #[serde(default = "default_router_ema_alpha")]
265    pub router_ema_alpha: f64,
266    #[serde(default = "default_router_reorder_interval")]
267    pub router_reorder_interval: u64,
268    /// Routing configuration for Thompson/Cascade strategies.
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub router: Option<RouterConfig>,
271    /// Provider-specific instruction file to inject into the system prompt.
272    /// Merged with `agent.instruction_files` at startup.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub instruction_file: Option<std::path::PathBuf>,
275    /// Shorthand model spec for tool-pair summarization and context compaction.
276    /// Format: `ollama/<model>`, `claude[/<model>]`, `openai[/<model>]`, `compatible/<name>`, `candle`.
277    /// Ignored when `[llm.summary_provider]` is set.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub summary_model: Option<String>,
280    /// Structured provider config for summarization. Takes precedence over `summary_model`.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub summary_provider: Option<ProviderEntry>,
283
284    /// Complexity triage routing configuration. Required when `routing = "triage"`.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub complexity_routing: Option<ComplexityRoutingConfig>,
287
288    /// Collaborative Entropy (`CoE`) configuration. `None` = `CoE` disabled.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub coe: Option<CoeConfig>,
291
292    /// SSE streaming buffer size limits.
293    ///
294    /// Controls the maximum bytes accumulated in per-block SSE buffers before excess
295    /// data is silently discarded. All fields have sane defaults; omitting the section
296    /// keeps pre-existing behavior.
297    #[serde(default, skip_serializing_if = "stream_limits_is_default")]
298    pub stream_limits: StreamLimits,
299}
300
301fn default_embedding_model_opt() -> String {
302    default_embedding_model()
303}
304
305impl Default for LlmConfig {
306    fn default() -> Self {
307        toml::from_str("").expect("empty TOML produces valid LlmConfig defaults")
308    }
309}
310
311#[allow(clippy::trivially_copy_pass_by_ref)]
312fn is_routing_none(s: &LlmRoutingStrategy) -> bool {
313    *s == LlmRoutingStrategy::None
314}
315
316impl LlmConfig {
317    /// Effective provider kind for the primary (first/default) provider in the pool.
318    #[must_use]
319    pub fn effective_provider(&self) -> ProviderKind {
320        self.providers
321            .first()
322            .map_or(ProviderKind::Ollama, |e| e.provider_type)
323    }
324
325    /// Effective base URL for the primary provider.
326    #[must_use]
327    pub fn effective_base_url(&self) -> &str {
328        self.providers
329            .first()
330            .and_then(|e| e.base_url.as_deref())
331            .unwrap_or("http://localhost:11434")
332    }
333
334    /// Effective model for the primary chat-capable provider.
335    ///
336    /// Skips embed-only entries (those with `embed = true`) and returns the model of the
337    /// first provider that can handle chat requests. Falls back to `"qwen3:8b"` when no
338    /// chat-capable provider is configured.
339    #[must_use]
340    pub fn effective_model(&self) -> &str {
341        self.providers
342            .iter()
343            .find(|e| !e.embed)
344            .and_then(|e| e.model.as_deref())
345            .unwrap_or("qwen3:8b")
346    }
347
348    /// Find the provider entry designated for STT.
349    ///
350    /// Resolution priority:
351    /// 1. `[llm.stt].provider` matches `[[llm.providers]].name` and the entry has `stt_model`
352    /// 2. `[llm.stt].provider` is empty — fall through to auto-detect
353    /// 3. First provider with `stt_model` set (auto-detect fallback)
354    /// 4. `None` — STT disabled
355    #[must_use]
356    pub fn stt_provider_entry(&self) -> Option<&ProviderEntry> {
357        let name_hint = self.stt.as_ref().map_or("", |s| s.provider.as_str());
358        if name_hint.is_empty() {
359            self.providers.iter().find(|p| p.stt_model.is_some())
360        } else {
361            self.providers
362                .iter()
363                .find(|p| p.effective_name() == name_hint && p.stt_model.is_some())
364        }
365    }
366
367    /// Returns the name of the effective embedding model.
368    ///
369    /// Resolution order:
370    /// 1. `embedding_model` from the `[[llm.providers]]` entry marked `embed = true`
371    /// 2. `embedding_model` from the first entry in `[[llm.providers]]`
372    /// 3. `[llm] embedding_model` global fallback (defaults to `"nomic-embed-text"`)
373    ///
374    /// # Examples
375    ///
376    /// ```
377    /// use zeph_config::providers::LlmConfig;
378    ///
379    /// let cfg = LlmConfig::default();
380    /// assert!(!cfg.effective_embedding_model().is_empty());
381    /// ```
382    #[must_use]
383    pub fn effective_embedding_model(&self) -> String {
384        if let Some(m) = self
385            .providers
386            .iter()
387            .find(|e| e.embed)
388            .and_then(|e| e.embedding_model.as_ref())
389        {
390            return m.clone();
391        }
392        if let Some(m) = self
393            .providers
394            .first()
395            .and_then(|e| e.embedding_model.as_ref())
396        {
397            return m.clone();
398        }
399        self.embedding_model.clone()
400    }
401
402    /// Returns the name of the stable skill embedding model.
403    ///
404    /// Prefers the `[[llm.providers]]` entry with `embed = true`, using its
405    /// `embedding_model` field first and `model` field as a secondary fallback.
406    /// Falls back to [`Self::effective_embedding_model`] when no dedicated embed
407    /// entry exists. Using the actual provider model name prevents false-positive
408    /// collection rebuilds in `zeph_memory::embedding_registry`.
409    ///
410    /// # Examples
411    ///
412    /// ```
413    /// use zeph_config::providers::LlmConfig;
414    ///
415    /// let cfg = LlmConfig::default();
416    /// assert!(!cfg.stable_skill_embedding_model().is_empty());
417    /// ```
418    #[must_use]
419    pub fn stable_skill_embedding_model(&self) -> String {
420        let embed_entry = self
421            .providers
422            .iter()
423            .find(|e| e.embed)
424            .or_else(|| self.providers.iter().find(|e| e.embedding_model.is_some()));
425
426        if let Some(entry) = embed_entry {
427            if let Some(em) = entry.embedding_model.as_ref().filter(|s| !s.is_empty()) {
428                return em.clone();
429            }
430            if let Some(m) = entry.model.as_ref().filter(|s| !s.is_empty()) {
431                return m.clone();
432            }
433        }
434
435        self.effective_embedding_model()
436    }
437
438    /// Validate that the config uses the new `[[llm.providers]]` format.
439    ///
440    /// # Errors
441    ///
442    /// Returns `ConfigError::Validation` when no providers are configured.
443    pub fn check_legacy_format(&self) -> Result<(), crate::error::ConfigError> {
444        Ok(())
445    }
446
447    /// Validate STT config cross-references.
448    ///
449    /// # Errors
450    ///
451    /// Returns `ConfigError::Validation` when the referenced STT provider does not exist.
452    #[must_use = "validation result must be checked"]
453    pub fn validate_stt(&self) -> Result<(), crate::error::ConfigError> {
454        use crate::error::ConfigError;
455
456        let Some(stt) = &self.stt else {
457            return Ok(());
458        };
459        if stt.provider.is_empty() {
460            return Ok(());
461        }
462        let found = self
463            .providers
464            .iter()
465            .find(|p| p.effective_name() == stt.provider.as_str());
466        match found {
467            None => {
468                return Err(ConfigError::Validation(format!(
469                    "[llm.stt].provider = {:?} does not match any [[llm.providers]] entry",
470                    stt.provider.as_str()
471                )));
472            }
473            Some(entry) if entry.stt_model.is_none() => {
474                tracing::warn!(
475                    provider = stt.provider.as_str(),
476                    "[[llm.providers]] entry exists but has no `stt_model` — STT will not be activated"
477                );
478            }
479            _ => {}
480        }
481        Ok(())
482    }
483
484    /// Resolve `provider_name` to its model string and emit a startup warning when the
485    /// model does not look like a fast-tier model.
486    ///
487    /// **Soft check — never returns an error.** Misconfiguration produces a single
488    /// `tracing::warn!` at startup so operators can fix configs without being blocked.
489    ///
490    /// Rules:
491    /// - Empty `provider_name` → silently OK (caller will use the primary provider).
492    /// - Provider not found in pool → warns `"<label> provider '<name>' not found"`.
493    /// - Model resolved but not in `FAST_TIER_MODEL_HINTS` and not in `extra_allowlist` →
494    ///   warns `"<label> provider '<name>' uses '<model>' which may not be fast-tier"`.
495    /// - Model matches a hint or allowlist entry → silently OK.
496    ///
497    /// # Examples
498    ///
499    /// ```no_run
500    /// use zeph_config::providers::{LlmConfig, ProviderName};
501    ///
502    /// // LlmConfig is constructed via config file; here we illustrate the call shape.
503    /// # let cfg: LlmConfig = unimplemented!();
504    /// // empty provider name is silently ok
505    /// cfg.warn_non_fast_tier_provider(&ProviderName::default(), "memcot.distill_provider", &[]);
506    /// ```
507    pub fn warn_non_fast_tier_provider(
508        &self,
509        provider_name: &ProviderName,
510        feature_label: &str,
511        extra_allowlist: &[String],
512    ) {
513        if provider_name.is_empty() {
514            return;
515        }
516        let name = provider_name.as_str();
517        let Some(entry) = self.providers.iter().find(|p| p.effective_name() == name) else {
518            tracing::warn!(
519                provider = name,
520                "{feature_label} provider '{name}' not found in [[llm.providers]]"
521            );
522            return;
523        };
524        let model = entry.model.as_deref().unwrap_or("");
525        if model.is_empty() {
526            return;
527        }
528        let lower = model.to_lowercase();
529        let in_hints = FAST_TIER_MODEL_HINTS.iter().any(|h| lower.contains(h));
530        let in_extra = extra_allowlist.iter().any(|h| lower.contains(h.as_str()));
531        if !in_hints && !in_extra {
532            tracing::warn!(
533                provider = name,
534                actual = model,
535                "{feature_label} provider '{name}' uses model '{model}' \
536                 which may not be fast-tier; prefer a fast model to bound distillation cost"
537            );
538        }
539    }
540}
541
542/// Lowercased substrings that identify commonly accepted fast-tier models.
543///
544/// Used by [`LlmConfig::warn_non_fast_tier_provider`] for a soft startup check.
545/// Updating this list is non-breaking; missing a fast model only suppresses a warning.
546pub const FAST_TIER_MODEL_HINTS: &[&str] = &[
547    "gpt-4o-mini",
548    "gpt-4.1-mini",
549    "gpt-5-mini",
550    "gpt-5-nano",
551    "claude-haiku",
552    "claude-3-haiku",
553    "claude-3-5-haiku",
554    "qwen3:8b",
555    "qwen2.5:7b",
556    "qwen2:7b",
557    "llama3.2:3b",
558    "llama3.1:8b",
559    "gemma3:4b",
560    "gemma3:8b",
561    "phi4:mini",
562    "mistral:7b",
563];
564
565/// Speech-to-text configuration, nested under `[llm.stt]` in TOML.
566///
567/// When set, Zeph uses the referenced provider for voice transcription.
568/// The provider must have an `stt_model` field set in its `[[llm.providers]]` entry.
569///
570/// # Example (TOML)
571///
572/// ```toml
573/// [llm.stt]
574/// provider = "fast"
575/// language = "en"
576/// ```
577#[derive(Debug, Clone, Deserialize, Serialize)]
578pub struct SttConfig {
579    /// Provider name from `[[llm.providers]]`. Empty means auto-detect the first provider
580    /// with `stt_model` set.
581    #[serde(default)]
582    pub provider: ProviderName,
583    /// Language hint for transcription (e.g. `"en"`, `"auto"`).
584    #[serde(default = "default_stt_language")]
585    pub language: String,
586}