use serde::{Deserialize, Serialize};
use super::{
CacheTtl, CandleInlineConfig, GeminiThinkingLevel, ProviderKind, ThinkingConfig, default_true,
is_true,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct GonkaNode {
pub url: String,
pub address: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct CocoonPricing {
#[serde(default)]
pub prompt_cents_per_1k: f64,
#[serde(default)]
pub completion_cents_per_1k: f64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[allow(clippy::struct_excessive_bools)] pub struct ProviderEntry {
#[serde(rename = "type")]
pub provider_type: ProviderKind,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub max_tokens: Option<u32>,
#[serde(default)]
pub embedding_model: Option<String>,
#[serde(default)]
pub stt_model: Option<String>,
#[serde(default)]
pub stt_model_sha256: Option<String>,
#[serde(default)]
pub embed: bool,
#[serde(default)]
pub default: bool,
#[serde(default)]
pub thinking: Option<ThinkingConfig>,
#[serde(default)]
pub server_compaction: bool,
#[serde(default)]
pub enable_extended_context: bool,
#[serde(default)]
pub prompt_cache_ttl: Option<CacheTtl>,
#[serde(default)]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub thinking_level: Option<GeminiThinkingLevel>,
#[serde(default)]
pub thinking_budget: Option<i32>,
#[serde(default)]
pub include_thoughts: Option<bool>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub candle: Option<CandleInlineConfig>,
#[serde(default)]
pub vision_model: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub gonka_nodes: Vec<GonkaNode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gonka_chain_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cocoon_client_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cocoon_access_hash: Option<String>,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
pub cocoon_health_check: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cocoon_pricing: Option<CocoonPricing>,
#[serde(default)]
pub instruction_file: Option<std::path::PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent: Option<u32>,
}
impl Default for ProviderEntry {
fn default() -> Self {
Self {
provider_type: ProviderKind::Ollama,
name: None,
model: None,
base_url: None,
max_tokens: None,
embedding_model: None,
stt_model: None,
stt_model_sha256: None,
embed: false,
default: false,
thinking: None,
server_compaction: false,
enable_extended_context: false,
prompt_cache_ttl: None,
reasoning_effort: None,
thinking_level: None,
thinking_budget: None,
include_thoughts: None,
api_key: None,
candle: None,
vision_model: None,
gonka_nodes: Vec::new(),
gonka_chain_prefix: None,
cocoon_client_url: None,
cocoon_access_hash: None,
cocoon_health_check: true,
cocoon_pricing: None,
instruction_file: None,
max_concurrent: None,
}
}
}
impl ProviderEntry {
#[must_use]
pub fn effective_name(&self) -> String {
self.name
.clone()
.unwrap_or_else(|| self.provider_type.as_str().to_owned())
}
#[must_use]
pub fn effective_model(&self) -> String {
if let Some(ref m) = self.model {
return m.clone();
}
match self.provider_type {
ProviderKind::Ollama => "qwen3:8b".to_owned(),
ProviderKind::Claude => "claude-haiku-4-5-20251001".to_owned(),
ProviderKind::OpenAi => "gpt-4o-mini".to_owned(),
ProviderKind::Gemini => "gemini-2.0-flash".to_owned(),
ProviderKind::Compatible | ProviderKind::Candle | ProviderKind::Gonka => String::new(),
ProviderKind::Cocoon => "Qwen/Qwen3-0.6B".to_owned(),
}
}
#[must_use = "validation result must be checked"]
pub fn validate(&self) -> Result<(), crate::error::ConfigError> {
use crate::error::ConfigError;
if self.provider_type == ProviderKind::Compatible && self.name.is_none() {
return Err(ConfigError::Validation(
"[[llm.providers]] entry with type=\"compatible\" must set `name`".into(),
));
}
if self.provider_type == ProviderKind::Gonka {
if self.name.is_none() {
return Err(ConfigError::Validation(
"[[llm.providers]] entry with type=\"gonka\" must set `name`".into(),
));
}
self.validate_gonka_nodes()?;
}
if self.provider_type == ProviderKind::Cocoon
&& self.name.as_ref().is_none_or(String::is_empty)
{
return Err(ConfigError::Validation(
"[[llm.providers]] entry with type=\"cocoon\" must set `name`".into(),
));
}
if self.provider_type == ProviderKind::Cocoon {
let name = self.effective_name();
if let Some(ref url_str) = self.cocoon_client_url {
match url::Url::parse(url_str) {
Err(_) => {
return Err(ConfigError::Validation(format!(
"[[llm.providers]] entry '{name}': cocoon_client_url \
'{url_str}' is not a valid URL; expected format: \
http://localhost:10000"
)));
}
Ok(u) if !matches!(u.host_str(), Some("localhost" | "127.0.0.1" | "::1")) => {
return Err(ConfigError::Validation(format!(
"[[llm.providers]] entry '{name}': cocoon_client_url host must be \
localhost or 127.0.0.1, got '{}'",
u.host_str().unwrap_or("<none>")
)));
}
Ok(u) if u.scheme() != "http" && u.scheme() != "https" => {
return Err(ConfigError::Validation(format!(
"[[llm.providers]] entry '{name}': cocoon_client_url \
scheme must be http or https, got '{}'",
u.scheme()
)));
}
_ => {}
}
}
if self.model.as_deref().is_some_and(|m| m.trim().is_empty()) {
return Err(ConfigError::Validation(format!(
"[[llm.providers]] entry '{name}': model must not be empty \
for cocoon provider"
)));
}
if let Some(ref p) = self.cocoon_pricing {
if !p.prompt_cents_per_1k.is_finite() || p.prompt_cents_per_1k < 0.0 {
return Err(ConfigError::Validation(format!(
"[[llm.providers]] entry '{name}': cocoon_pricing.prompt_cents_per_1k \
must be a finite non-negative number"
)));
}
if !p.completion_cents_per_1k.is_finite() || p.completion_cents_per_1k < 0.0 {
return Err(ConfigError::Validation(format!(
"[[llm.providers]] entry '{name}': \
cocoon_pricing.completion_cents_per_1k \
must be a finite non-negative number"
)));
}
}
}
self.warn_irrelevant_fields();
if self.stt_model.is_some() && self.provider_type == ProviderKind::Ollama {
tracing::warn!(
provider = self.effective_name(),
"field `stt_model` is set on an Ollama provider; Ollama does not support the \
Whisper STT API — use OpenAI, compatible, or candle instead"
);
}
Ok(())
}
#[must_use]
pub fn effective_gonka_chain_prefix(&self) -> &str {
self.gonka_chain_prefix.as_deref().unwrap_or("gonka")
}
fn warn_irrelevant_fields(&self) {
let name = self.effective_name();
match self.provider_type {
ProviderKind::Ollama => {
if self.thinking.is_some() {
tracing::warn!(
provider = name,
"field `thinking` is only used by Claude providers"
);
}
if self.reasoning_effort.is_some() {
tracing::warn!(
provider = name,
"field `reasoning_effort` is only used by OpenAI providers"
);
}
if self.thinking_level.is_some() || self.thinking_budget.is_some() {
tracing::warn!(
provider = name,
"fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
);
}
}
ProviderKind::Claude => {
if self.reasoning_effort.is_some() {
tracing::warn!(
provider = name,
"field `reasoning_effort` is only used by OpenAI providers"
);
}
if self.thinking_level.is_some() || self.thinking_budget.is_some() {
tracing::warn!(
provider = name,
"fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
);
}
}
ProviderKind::OpenAi => {
if self.thinking.is_some() {
tracing::warn!(
provider = name,
"field `thinking` is only used by Claude providers"
);
}
if self.thinking_level.is_some() || self.thinking_budget.is_some() {
tracing::warn!(
provider = name,
"fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
);
}
}
ProviderKind::Gemini => {
if self.thinking.is_some() {
tracing::warn!(
provider = name,
"field `thinking` is only used by Claude providers"
);
}
if self.reasoning_effort.is_some() {
tracing::warn!(
provider = name,
"field `reasoning_effort` is only used by OpenAI providers"
);
}
}
ProviderKind::Gonka => {
if self.thinking.is_some() {
tracing::warn!(
provider = name,
"field `thinking` is only used by Claude providers"
);
}
if self.reasoning_effort.is_some() {
tracing::warn!(
provider = name,
"field `reasoning_effort` is only used by OpenAI providers"
);
}
if self.thinking_level.is_some() || self.thinking_budget.is_some() {
tracing::warn!(
provider = name,
"fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
);
}
}
ProviderKind::Compatible | ProviderKind::Candle => {}
ProviderKind::Cocoon => {
if self.base_url.is_some() {
tracing::warn!(
provider = name,
"field `base_url` is ignored for cocoon providers; use `cocoon_client_url` instead"
);
}
}
}
}
fn validate_gonka_nodes(&self) -> Result<(), crate::error::ConfigError> {
use crate::error::ConfigError;
if self.gonka_nodes.is_empty() {
return Err(ConfigError::Validation(format!(
"[[llm.providers]] entry '{}' with type=\"gonka\" must set non-empty `gonka_nodes`",
self.effective_name()
)));
}
for (i, node) in self.gonka_nodes.iter().enumerate() {
if node.url.is_empty() {
return Err(ConfigError::Validation(format!(
"[[llm.providers]] entry '{}' gonka_nodes[{i}].url must not be empty",
self.effective_name()
)));
}
if !node.url.starts_with("http://") && !node.url.starts_with("https://") {
return Err(ConfigError::Validation(format!(
"[[llm.providers]] entry '{}' gonka_nodes[{i}].url must start with http:// or https://",
self.effective_name()
)));
}
}
Ok(())
}
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ProviderOverrides {
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
}
impl ProviderOverrides {
#[must_use]
pub fn is_empty(&self) -> bool {
self.reasoning_effort.is_none()
}
}
#[must_use = "validation result must be checked"]
pub fn validate_pool(entries: &[ProviderEntry]) -> Result<(), crate::error::ConfigError> {
use crate::error::ConfigError;
use std::collections::HashSet;
if entries.is_empty() {
return Err(ConfigError::Validation(
"at least one LLM provider must be configured in [[llm.providers]]".into(),
));
}
let default_count = entries.iter().filter(|e| e.default).count();
if default_count > 1 {
return Err(ConfigError::Validation(
"only one [[llm.providers]] entry can be marked `default = true`".into(),
));
}
let mut seen_names: HashSet<String> = HashSet::new();
for entry in entries {
let name = entry.effective_name();
if !seen_names.insert(name.clone()) {
return Err(ConfigError::Validation(format!(
"duplicate provider name \"{name}\" in [[llm.providers]]"
)));
}
entry.validate()?;
}
Ok(())
}