1use serde::{Deserialize, Serialize};
12
13use super::{
14 CacheTtl, CandleInlineConfig, GeminiThinkingLevel, ProviderKind, ThinkingConfig, default_true,
15 is_true,
16};
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
23pub struct GonkaNode {
24 pub url: String,
26 pub address: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub name: Option<String>,
34}
35#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
44pub struct CocoonPricing {
45 #[serde(default)]
47 pub prompt_cents_per_1k: f64,
48 #[serde(default)]
51 pub completion_cents_per_1k: f64,
52}
53
54#[derive(Clone, Deserialize, Serialize)]
60#[allow(clippy::struct_excessive_bools)] pub struct ProviderEntry {
62 #[serde(rename = "type")]
64 pub provider_type: ProviderKind,
65
66 #[serde(default)]
68 pub name: Option<String>,
69
70 #[serde(default)]
72 pub model: Option<String>,
73
74 #[serde(default)]
76 pub base_url: Option<String>,
77
78 #[serde(default)]
80 pub max_tokens: Option<u32>,
81
82 #[serde(default)]
84 pub embedding_model: Option<String>,
85
86 #[serde(default)]
89 pub stt_model: Option<String>,
90
91 #[serde(default)]
96 pub stt_model_sha256: Option<String>,
97
98 #[serde(default)]
100 pub embed: bool,
101
102 #[serde(default)]
104 pub default: bool,
105
106 #[serde(default)]
108 pub thinking: Option<ThinkingConfig>,
109 #[serde(default)]
110 pub server_compaction: bool,
111 #[serde(default)]
112 pub enable_extended_context: bool,
113 #[serde(default)]
116 pub prompt_cache_ttl: Option<CacheTtl>,
117
118 #[serde(default)]
120 pub reasoning_effort: Option<String>,
121
122 #[serde(default)]
124 pub thinking_level: Option<GeminiThinkingLevel>,
125 #[serde(default)]
126 pub thinking_budget: Option<i32>,
127 #[serde(default)]
128 pub include_thoughts: Option<bool>,
129
130 #[serde(default)]
132 pub api_key: Option<String>,
133
134 #[serde(default)]
136 pub candle: Option<CandleInlineConfig>,
137
138 #[serde(default)]
140 pub vision_model: Option<String>,
141
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
145 pub gonka_nodes: Vec<GonkaNode>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub gonka_chain_prefix: Option<String>,
149
150 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub cocoon_client_url: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub cocoon_access_hash: Option<String>,
158 #[serde(default = "default_true", skip_serializing_if = "is_true")]
160 pub cocoon_health_check: bool,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub cocoon_pricing: Option<CocoonPricing>,
175
176 #[serde(default)]
178 pub instruction_file: Option<std::path::PathBuf>,
179
180 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub max_concurrent: Option<u32>,
199}
200
201impl Default for ProviderEntry {
202 fn default() -> Self {
203 Self {
204 provider_type: ProviderKind::Ollama,
205 name: None,
206 model: None,
207 base_url: None,
208 max_tokens: None,
209 embedding_model: None,
210 stt_model: None,
211 stt_model_sha256: None,
212 embed: false,
213 default: false,
214 thinking: None,
215 server_compaction: false,
216 enable_extended_context: false,
217 prompt_cache_ttl: None,
218 reasoning_effort: None,
219 thinking_level: None,
220 thinking_budget: None,
221 include_thoughts: None,
222 api_key: None,
223 candle: None,
224 vision_model: None,
225 gonka_nodes: Vec::new(),
226 gonka_chain_prefix: None,
227 cocoon_client_url: None,
228 cocoon_access_hash: None,
229 cocoon_health_check: true,
230 cocoon_pricing: None,
231 instruction_file: None,
232 max_concurrent: None,
233 }
234 }
235}
236
237impl std::fmt::Debug for ProviderEntry {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 f.debug_struct("ProviderEntry")
240 .field("provider_type", &self.provider_type)
241 .field("name", &self.name)
242 .field("model", &self.model)
243 .field("base_url", &self.base_url)
244 .field("max_tokens", &self.max_tokens)
245 .field("embedding_model", &self.embedding_model)
246 .field("stt_model", &self.stt_model)
247 .field("stt_model_sha256", &self.stt_model_sha256)
248 .field("embed", &self.embed)
249 .field("default", &self.default)
250 .field("thinking", &self.thinking)
251 .field("server_compaction", &self.server_compaction)
252 .field("enable_extended_context", &self.enable_extended_context)
253 .field("prompt_cache_ttl", &self.prompt_cache_ttl)
254 .field("reasoning_effort", &self.reasoning_effort)
255 .field("thinking_level", &self.thinking_level)
256 .field("thinking_budget", &self.thinking_budget)
257 .field("include_thoughts", &self.include_thoughts)
258 .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
259 .field("candle", &self.candle)
260 .field("vision_model", &self.vision_model)
261 .field("gonka_nodes", &self.gonka_nodes)
262 .field("gonka_chain_prefix", &self.gonka_chain_prefix)
263 .field("cocoon_client_url", &self.cocoon_client_url)
264 .field(
265 "cocoon_access_hash",
266 &self.cocoon_access_hash.as_ref().map(|_| "[REDACTED]"),
267 )
268 .field("cocoon_health_check", &self.cocoon_health_check)
269 .field("cocoon_pricing", &self.cocoon_pricing)
270 .field("instruction_file", &self.instruction_file)
271 .field("max_concurrent", &self.max_concurrent)
272 .finish()
273 }
274}
275
276impl ProviderEntry {
277 #[must_use]
279 pub fn effective_name(&self) -> String {
280 self.name
281 .clone()
282 .unwrap_or_else(|| self.provider_type.as_str().to_owned())
283 }
284
285 #[must_use]
290 pub fn effective_model(&self) -> String {
291 if let Some(ref m) = self.model {
292 return m.clone();
293 }
294 match self.provider_type {
295 ProviderKind::Ollama => "qwen3:8b".to_owned(),
296 ProviderKind::Claude => "claude-haiku-4-5-20251001".to_owned(),
297 ProviderKind::OpenAi => "gpt-4o-mini".to_owned(),
298 ProviderKind::Gemini => "gemini-2.0-flash".to_owned(),
299 ProviderKind::Compatible | ProviderKind::Candle | ProviderKind::Gonka => String::new(),
302 ProviderKind::Cocoon => "Qwen/Qwen3-0.6B".to_owned(),
303 }
304 }
305
306 #[must_use = "validation result must be checked"]
313 pub fn validate(&self) -> Result<(), crate::error::ConfigError> {
314 use crate::error::ConfigError;
315
316 if self.provider_type == ProviderKind::Compatible && self.name.is_none() {
318 return Err(ConfigError::Validation(
319 "[[llm.providers]] entry with type=\"compatible\" must set `name`".into(),
320 ));
321 }
322
323 if self.provider_type == ProviderKind::Gonka {
325 if self.name.is_none() {
326 return Err(ConfigError::Validation(
327 "[[llm.providers]] entry with type=\"gonka\" must set `name`".into(),
328 ));
329 }
330 self.validate_gonka_nodes()?;
331 }
332
333 if self.provider_type == ProviderKind::Cocoon
335 && self.name.as_ref().is_none_or(String::is_empty)
336 {
337 return Err(ConfigError::Validation(
338 "[[llm.providers]] entry with type=\"cocoon\" must set `name`".into(),
339 ));
340 }
341
342 if self.provider_type == ProviderKind::Cocoon {
344 let name = self.effective_name();
345 if let Some(ref url_str) = self.cocoon_client_url {
346 match url::Url::parse(url_str) {
347 Err(_) => {
348 return Err(ConfigError::Validation(format!(
349 "[[llm.providers]] entry '{name}': cocoon_client_url \
350 '{url_str}' is not a valid URL; expected format: \
351 http://localhost:10000"
352 )));
353 }
354 Ok(u) if !matches!(u.host_str(), Some("localhost" | "127.0.0.1" | "::1")) => {
355 return Err(ConfigError::Validation(format!(
356 "[[llm.providers]] entry '{name}': cocoon_client_url host must be \
357 localhost or 127.0.0.1, got '{}'",
358 u.host_str().unwrap_or("<none>")
359 )));
360 }
361 Ok(u) if u.scheme() != "http" && u.scheme() != "https" => {
362 return Err(ConfigError::Validation(format!(
363 "[[llm.providers]] entry '{name}': cocoon_client_url \
364 scheme must be http or https, got '{}'",
365 u.scheme()
366 )));
367 }
368 _ => {}
369 }
370 }
371 if self.model.as_deref().is_some_and(|m| m.trim().is_empty()) {
372 return Err(ConfigError::Validation(format!(
373 "[[llm.providers]] entry '{name}': model must not be empty \
374 for cocoon provider"
375 )));
376 }
377 if let Some(ref p) = self.cocoon_pricing {
378 if !p.prompt_cents_per_1k.is_finite() || p.prompt_cents_per_1k < 0.0 {
379 return Err(ConfigError::Validation(format!(
380 "[[llm.providers]] entry '{name}': cocoon_pricing.prompt_cents_per_1k \
381 must be a finite non-negative number"
382 )));
383 }
384 if !p.completion_cents_per_1k.is_finite() || p.completion_cents_per_1k < 0.0 {
385 return Err(ConfigError::Validation(format!(
386 "[[llm.providers]] entry '{name}': \
387 cocoon_pricing.completion_cents_per_1k \
388 must be a finite non-negative number"
389 )));
390 }
391 }
392 }
393
394 self.warn_irrelevant_fields();
396
397 if self.stt_model.is_some() && self.provider_type == ProviderKind::Ollama {
400 tracing::warn!(
401 provider = self.effective_name(),
402 "field `stt_model` is set on an Ollama provider; Ollama does not support the \
403 Whisper STT API — use OpenAI, compatible, or candle instead"
404 );
405 }
406
407 Ok(())
408 }
409
410 #[must_use]
412 pub fn effective_gonka_chain_prefix(&self) -> &str {
413 self.gonka_chain_prefix.as_deref().unwrap_or("gonka")
414 }
415
416 fn warn_irrelevant_fields(&self) {
417 let name = self.effective_name();
418 match self.provider_type {
419 ProviderKind::Ollama => {
420 if self.thinking.is_some() {
421 tracing::warn!(
422 provider = name,
423 "field `thinking` is only used by Claude providers"
424 );
425 }
426 if self.reasoning_effort.is_some() {
427 tracing::warn!(
428 provider = name,
429 "field `reasoning_effort` is only used by OpenAI providers"
430 );
431 }
432 if self.thinking_level.is_some() || self.thinking_budget.is_some() {
433 tracing::warn!(
434 provider = name,
435 "fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
436 );
437 }
438 }
439 ProviderKind::Claude => {
440 if self.reasoning_effort.is_some() {
441 tracing::warn!(
442 provider = name,
443 "field `reasoning_effort` is only used by OpenAI providers"
444 );
445 }
446 if self.thinking_level.is_some() || self.thinking_budget.is_some() {
447 tracing::warn!(
448 provider = name,
449 "fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
450 );
451 }
452 }
453 ProviderKind::OpenAi => {
454 if self.thinking.is_some() {
455 tracing::warn!(
456 provider = name,
457 "field `thinking` is only used by Claude providers"
458 );
459 }
460 if self.thinking_level.is_some() || self.thinking_budget.is_some() {
461 tracing::warn!(
462 provider = name,
463 "fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
464 );
465 }
466 }
467 ProviderKind::Gemini => {
468 if self.thinking.is_some() {
469 tracing::warn!(
470 provider = name,
471 "field `thinking` is only used by Claude providers"
472 );
473 }
474 if self.reasoning_effort.is_some() {
475 tracing::warn!(
476 provider = name,
477 "field `reasoning_effort` is only used by OpenAI providers"
478 );
479 }
480 }
481 ProviderKind::Gonka => {
482 if self.thinking.is_some() {
483 tracing::warn!(
484 provider = name,
485 "field `thinking` is only used by Claude providers"
486 );
487 }
488 if self.reasoning_effort.is_some() {
489 tracing::warn!(
490 provider = name,
491 "field `reasoning_effort` is only used by OpenAI providers"
492 );
493 }
494 if self.thinking_level.is_some() || self.thinking_budget.is_some() {
495 tracing::warn!(
496 provider = name,
497 "fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
498 );
499 }
500 }
501 ProviderKind::Compatible | ProviderKind::Candle => {}
502 ProviderKind::Cocoon => {
503 if self.base_url.is_some() {
504 tracing::warn!(
505 provider = name,
506 "field `base_url` is ignored for cocoon providers; use `cocoon_client_url` instead"
507 );
508 }
509 }
510 }
511 }
512
513 fn validate_gonka_nodes(&self) -> Result<(), crate::error::ConfigError> {
514 use crate::error::ConfigError;
515 if self.gonka_nodes.is_empty() {
516 return Err(ConfigError::Validation(format!(
517 "[[llm.providers]] entry '{}' with type=\"gonka\" must set non-empty `gonka_nodes`",
518 self.effective_name()
519 )));
520 }
521 for (i, node) in self.gonka_nodes.iter().enumerate() {
522 if node.url.is_empty() {
523 return Err(ConfigError::Validation(format!(
524 "[[llm.providers]] entry '{}' gonka_nodes[{i}].url must not be empty",
525 self.effective_name()
526 )));
527 }
528 if !node.url.starts_with("http://") && !node.url.starts_with("https://") {
529 return Err(ConfigError::Validation(format!(
530 "[[llm.providers]] entry '{}' gonka_nodes[{i}].url must start with http:// or https://",
531 self.effective_name()
532 )));
533 }
534 }
535 Ok(())
536 }
537}
538
539#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
563#[serde(default)]
564pub struct ProviderOverrides {
565 #[serde(skip_serializing_if = "Option::is_none")]
567 pub reasoning_effort: Option<String>,
568}
569
570impl ProviderOverrides {
571 #[must_use]
584 pub fn is_empty(&self) -> bool {
585 self.reasoning_effort.is_none()
586 }
587}
588
589#[must_use = "validation result must be checked"]
599pub fn validate_pool(entries: &[ProviderEntry]) -> Result<(), crate::error::ConfigError> {
600 use crate::error::ConfigError;
601 use std::collections::HashSet;
602
603 if entries.is_empty() {
604 return Err(ConfigError::Validation(
605 "at least one LLM provider must be configured in [[llm.providers]]".into(),
606 ));
607 }
608
609 let default_count = entries.iter().filter(|e| e.default).count();
610 if default_count > 1 {
611 return Err(ConfigError::Validation(
612 "only one [[llm.providers]] entry can be marked `default = true`".into(),
613 ));
614 }
615
616 let mut seen_names: HashSet<String> = HashSet::new();
617 for entry in entries {
618 let name = entry.effective_name();
619 if !seen_names.insert(name.clone()) {
620 return Err(ConfigError::Validation(format!(
621 "duplicate provider name \"{name}\" in [[llm.providers]]"
622 )));
623 }
624 entry.validate()?;
625 }
626
627 Ok(())
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use crate::ProviderKind;
634
635 #[test]
636 fn provider_entry_debug_redacts_api_key() {
637 let entry = ProviderEntry {
638 api_key: Some("sk-SUPERSECRET".to_owned()),
639 ..ProviderEntry::default()
640 };
641 let dbg = format!("{entry:?}");
642 assert!(!dbg.contains("sk-SUPERSECRET"));
643 assert!(dbg.contains("[REDACTED]"));
644 }
645
646 #[test]
647 fn provider_entry_debug_none_api_key() {
648 let entry = ProviderEntry::default();
649 let dbg = format!("{entry:?}");
650 assert!(!dbg.contains("[REDACTED]"));
651 assert!(dbg.contains("api_key: None"));
652 }
653
654 #[test]
655 fn provider_entry_debug_redacts_nested_candle_hf_token() {
656 let entry = ProviderEntry {
657 provider_type: ProviderKind::Candle,
658 candle: Some(super::super::CandleInlineConfig {
659 hf_token: Some("hf_SUPERSECRET".to_owned()),
660 ..super::super::CandleInlineConfig::default()
661 }),
662 ..ProviderEntry::default()
663 };
664 let dbg = format!("{entry:?}");
665 assert!(!dbg.contains("hf_SUPERSECRET"));
666 assert!(dbg.contains("[REDACTED]"));
667 }
668}