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 `"qwen3-embedding"`)
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}