use serde::{Deserialize, Serialize};
use zeph_common::ProviderName;
use super::{
CandleConfig, CoeConfig, ComplexityRoutingConfig, LlmRoutingStrategy, ProviderEntry,
RouterConfig,
};
fn default_response_cache_ttl_secs() -> u64 {
3600
}
fn default_semantic_cache_threshold() -> f32 {
0.95
}
fn default_semantic_cache_max_candidates() -> u32 {
10
}
fn default_router_ema_alpha() -> f64 {
0.1
}
fn default_router_reorder_interval() -> u64 {
10
}
fn default_embedding_model() -> String {
"qwen3-embedding".into()
}
#[must_use]
pub fn default_stt_language() -> String {
"auto".into()
}
#[must_use]
pub(crate) fn get_default_embedding_model() -> String {
default_embedding_model()
}
#[must_use]
pub(crate) fn get_default_response_cache_ttl_secs() -> u64 {
default_response_cache_ttl_secs()
}
#[must_use]
pub(crate) fn get_default_router_ema_alpha() -> f64 {
default_router_ema_alpha()
}
#[must_use]
pub(crate) fn get_default_router_reorder_interval() -> u64 {
default_router_reorder_interval()
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ProviderKind {
Ollama,
Claude,
OpenAi,
Gemini,
Candle,
Compatible,
Gonka,
Cocoon,
}
impl ProviderKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Ollama => "ollama",
Self::Claude => "claude",
Self::OpenAi => "openai",
Self::Gemini => "gemini",
Self::Candle => "candle",
Self::Compatible => "compatible",
Self::Gonka => "gonka",
Self::Cocoon => "cocoon",
}
}
}
impl std::fmt::Display for ProviderKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
fn default_max_tool_json_bytes() -> usize {
4 * 1024 * 1024
}
fn default_max_thinking_bytes() -> usize {
1024 * 1024
}
fn default_max_compaction_bytes() -> usize {
32 * 1024
}
fn stream_limits_is_default(v: &StreamLimits) -> bool {
v.max_tool_json_bytes == default_max_tool_json_bytes()
&& v.max_thinking_bytes == default_max_thinking_bytes()
&& v.max_compaction_bytes == default_max_compaction_bytes()
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct StreamLimits {
#[serde(default = "default_max_tool_json_bytes")]
pub max_tool_json_bytes: usize,
#[serde(default = "default_max_thinking_bytes")]
pub max_thinking_bytes: usize,
#[serde(default = "default_max_compaction_bytes")]
pub max_compaction_bytes: usize,
}
impl Default for StreamLimits {
fn default() -> Self {
Self {
max_tool_json_bytes: default_max_tool_json_bytes(),
max_thinking_bytes: default_max_thinking_bytes(),
max_compaction_bytes: default_max_compaction_bytes(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct LlmConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub providers: Vec<ProviderEntry>,
#[serde(default, skip_serializing_if = "is_routing_none")]
pub routing: LlmRoutingStrategy,
#[serde(default = "default_embedding_model_opt")]
pub embedding_model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub candle: Option<CandleConfig>,
#[serde(default)]
pub stt: Option<SttConfig>,
#[serde(default)]
pub response_cache_enabled: bool,
#[serde(default = "default_response_cache_ttl_secs")]
pub response_cache_ttl_secs: u64,
#[serde(default)]
pub semantic_cache_enabled: bool,
#[serde(default = "default_semantic_cache_threshold")]
pub semantic_cache_threshold: f32,
#[serde(default = "default_semantic_cache_max_candidates")]
pub semantic_cache_max_candidates: u32,
#[serde(default)]
pub router_ema_enabled: bool,
#[serde(default = "default_router_ema_alpha")]
pub router_ema_alpha: f64,
#[serde(default = "default_router_reorder_interval")]
pub router_reorder_interval: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub router: Option<RouterConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instruction_file: Option<std::path::PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary_provider: Option<ProviderEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub complexity_routing: Option<ComplexityRoutingConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coe: Option<CoeConfig>,
#[serde(default, skip_serializing_if = "stream_limits_is_default")]
pub stream_limits: StreamLimits,
}
fn default_embedding_model_opt() -> String {
default_embedding_model()
}
impl Default for LlmConfig {
fn default() -> Self {
toml::from_str("").expect("empty TOML produces valid LlmConfig defaults")
}
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_routing_none(s: &LlmRoutingStrategy) -> bool {
*s == LlmRoutingStrategy::None
}
impl LlmConfig {
#[must_use]
pub fn effective_provider(&self) -> ProviderKind {
self.providers
.first()
.map_or(ProviderKind::Ollama, |e| e.provider_type)
}
#[must_use]
pub fn effective_base_url(&self) -> &str {
self.providers
.first()
.and_then(|e| e.base_url.as_deref())
.unwrap_or("http://localhost:11434")
}
#[must_use]
pub fn effective_model(&self) -> &str {
self.providers
.iter()
.find(|e| !e.embed)
.and_then(|e| e.model.as_deref())
.unwrap_or("qwen3:8b")
}
#[must_use]
pub fn stt_provider_entry(&self) -> Option<&ProviderEntry> {
let name_hint = self.stt.as_ref().map_or("", |s| s.provider.as_str());
if name_hint.is_empty() {
self.providers.iter().find(|p| p.stt_model.is_some())
} else {
self.providers
.iter()
.find(|p| p.effective_name() == name_hint && p.stt_model.is_some())
}
}
#[must_use]
pub fn effective_embedding_model(&self) -> String {
if let Some(m) = self
.providers
.iter()
.find(|e| e.embed)
.and_then(|e| e.embedding_model.as_ref())
{
return m.clone();
}
if let Some(m) = self
.providers
.first()
.and_then(|e| e.embedding_model.as_ref())
{
return m.clone();
}
self.embedding_model.clone()
}
#[must_use]
pub fn stable_skill_embedding_model(&self) -> String {
let embed_entry = self
.providers
.iter()
.find(|e| e.embed)
.or_else(|| self.providers.iter().find(|e| e.embedding_model.is_some()));
if let Some(entry) = embed_entry {
if let Some(em) = entry.embedding_model.as_ref().filter(|s| !s.is_empty()) {
return em.clone();
}
if let Some(m) = entry.model.as_ref().filter(|s| !s.is_empty()) {
return m.clone();
}
}
self.effective_embedding_model()
}
pub fn check_legacy_format(&self) -> Result<(), crate::error::ConfigError> {
Ok(())
}
pub fn validate_stt(&self) -> Result<(), crate::error::ConfigError> {
use crate::error::ConfigError;
let Some(stt) = &self.stt else {
return Ok(());
};
if stt.provider.is_empty() {
return Ok(());
}
let found = self
.providers
.iter()
.find(|p| p.effective_name() == stt.provider.as_str());
match found {
None => {
return Err(ConfigError::Validation(format!(
"[llm.stt].provider = {:?} does not match any [[llm.providers]] entry",
stt.provider.as_str()
)));
}
Some(entry) if entry.stt_model.is_none() => {
tracing::warn!(
provider = stt.provider.as_str(),
"[[llm.providers]] entry exists but has no `stt_model` — STT will not be activated"
);
}
_ => {}
}
Ok(())
}
pub fn warn_non_fast_tier_provider(
&self,
provider_name: &ProviderName,
feature_label: &str,
extra_allowlist: &[String],
) {
if provider_name.is_empty() {
return;
}
let name = provider_name.as_str();
let Some(entry) = self.providers.iter().find(|p| p.effective_name() == name) else {
tracing::warn!(
provider = name,
"{feature_label} provider '{name}' not found in [[llm.providers]]"
);
return;
};
let model = entry.model.as_deref().unwrap_or("");
if model.is_empty() {
return;
}
let lower = model.to_lowercase();
let in_hints = FAST_TIER_MODEL_HINTS.iter().any(|h| lower.contains(h));
let in_extra = extra_allowlist.iter().any(|h| lower.contains(h.as_str()));
if !in_hints && !in_extra {
tracing::warn!(
provider = name,
actual = model,
"{feature_label} provider '{name}' uses model '{model}' \
which may not be fast-tier; prefer a fast model to bound distillation cost"
);
}
}
}
pub const FAST_TIER_MODEL_HINTS: &[&str] = &[
"gpt-4o-mini",
"gpt-4.1-mini",
"gpt-5-mini",
"gpt-5-nano",
"claude-haiku",
"claude-3-haiku",
"claude-3-5-haiku",
"qwen3:8b",
"qwen2.5:7b",
"qwen2:7b",
"llama3.2:3b",
"llama3.1:8b",
"gemma3:4b",
"gemma3:8b",
"phi4:mini",
"mistral:7b",
];
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SttConfig {
#[serde(default)]
pub provider: ProviderName,
#[serde(default = "default_stt_language")]
pub language: String,
}