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 #[serde(default)]
150 pub vision: Option<bool>,
151
152 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 pub gonka_nodes: Vec<GonkaNode>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub gonka_chain_prefix: Option<String>,
159
160 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub cocoon_client_url: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub cocoon_access_hash: Option<String>,
168 #[serde(default = "default_true", skip_serializing_if = "is_true")]
170 pub cocoon_health_check: bool,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub cocoon_pricing: Option<CocoonPricing>,
185
186 #[serde(default)]
188 pub instruction_file: Option<std::path::PathBuf>,
189
190 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub max_concurrent: Option<u32>,
209}
210
211impl Default for ProviderEntry {
212 fn default() -> Self {
213 Self {
214 provider_type: ProviderKind::Ollama,
215 name: None,
216 model: None,
217 base_url: None,
218 max_tokens: None,
219 embedding_model: None,
220 stt_model: None,
221 stt_model_sha256: None,
222 embed: false,
223 default: false,
224 thinking: None,
225 server_compaction: false,
226 enable_extended_context: false,
227 prompt_cache_ttl: None,
228 reasoning_effort: None,
229 thinking_level: None,
230 thinking_budget: None,
231 include_thoughts: None,
232 api_key: None,
233 candle: None,
234 vision_model: None,
235 vision: None,
236 gonka_nodes: Vec::new(),
237 gonka_chain_prefix: None,
238 cocoon_client_url: None,
239 cocoon_access_hash: None,
240 cocoon_health_check: true,
241 cocoon_pricing: None,
242 instruction_file: None,
243 max_concurrent: None,
244 }
245 }
246}
247
248impl std::fmt::Debug for ProviderEntry {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 f.debug_struct("ProviderEntry")
251 .field("provider_type", &self.provider_type)
252 .field("name", &self.name)
253 .field("model", &self.model)
254 .field("base_url", &self.base_url)
255 .field("max_tokens", &self.max_tokens)
256 .field("embedding_model", &self.embedding_model)
257 .field("stt_model", &self.stt_model)
258 .field("stt_model_sha256", &self.stt_model_sha256)
259 .field("embed", &self.embed)
260 .field("default", &self.default)
261 .field("thinking", &self.thinking)
262 .field("server_compaction", &self.server_compaction)
263 .field("enable_extended_context", &self.enable_extended_context)
264 .field("prompt_cache_ttl", &self.prompt_cache_ttl)
265 .field("reasoning_effort", &self.reasoning_effort)
266 .field("thinking_level", &self.thinking_level)
267 .field("thinking_budget", &self.thinking_budget)
268 .field("include_thoughts", &self.include_thoughts)
269 .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
270 .field("candle", &self.candle)
271 .field("vision_model", &self.vision_model)
272 .field("vision", &self.vision)
273 .field("gonka_nodes", &self.gonka_nodes)
274 .field("gonka_chain_prefix", &self.gonka_chain_prefix)
275 .field("cocoon_client_url", &self.cocoon_client_url)
276 .field(
277 "cocoon_access_hash",
278 &self.cocoon_access_hash.as_ref().map(|_| "[REDACTED]"),
279 )
280 .field("cocoon_health_check", &self.cocoon_health_check)
281 .field("cocoon_pricing", &self.cocoon_pricing)
282 .field("instruction_file", &self.instruction_file)
283 .field("max_concurrent", &self.max_concurrent)
284 .finish()
285 }
286}
287
288impl ProviderEntry {
289 #[must_use]
291 pub fn effective_name(&self) -> String {
292 self.name
293 .clone()
294 .unwrap_or_else(|| self.provider_type.as_str().to_owned())
295 }
296
297 #[must_use]
302 pub fn effective_model(&self) -> String {
303 if let Some(ref m) = self.model {
304 return m.clone();
305 }
306 match self.provider_type {
307 ProviderKind::Ollama => "qwen3:8b".to_owned(),
308 ProviderKind::Claude => "claude-haiku-4-5-20251001".to_owned(),
309 ProviderKind::OpenAi => "gpt-4o-mini".to_owned(),
310 ProviderKind::Gemini => "gemini-2.0-flash".to_owned(),
311 ProviderKind::Compatible | ProviderKind::Candle | ProviderKind::Gonka => String::new(),
314 ProviderKind::Cocoon => "Qwen/Qwen3-0.6B".to_owned(),
315 }
316 }
317
318 #[must_use = "validation result must be checked"]
325 pub fn validate(&self) -> Result<(), crate::error::ConfigError> {
326 use crate::error::ConfigError;
327
328 if self.provider_type == ProviderKind::Compatible && self.name.is_none() {
330 return Err(ConfigError::Validation(
331 "[[llm.providers]] entry with type=\"compatible\" must set `name`".into(),
332 ));
333 }
334
335 if self.provider_type == ProviderKind::Gonka {
337 if self.name.is_none() {
338 return Err(ConfigError::Validation(
339 "[[llm.providers]] entry with type=\"gonka\" must set `name`".into(),
340 ));
341 }
342 self.validate_gonka_nodes()?;
343 }
344
345 if self.provider_type == ProviderKind::Cocoon
347 && self.name.as_ref().is_none_or(String::is_empty)
348 {
349 return Err(ConfigError::Validation(
350 "[[llm.providers]] entry with type=\"cocoon\" must set `name`".into(),
351 ));
352 }
353
354 if self.provider_type == ProviderKind::Cocoon {
356 let name = self.effective_name();
357 if let Some(ref url_str) = self.cocoon_client_url {
358 match url::Url::parse(url_str) {
359 Err(_) => {
360 return Err(ConfigError::Validation(format!(
361 "[[llm.providers]] entry '{name}': cocoon_client_url \
362 '{url_str}' is not a valid URL; expected format: \
363 http://localhost:10000"
364 )));
365 }
366 Ok(u) if !matches!(u.host_str(), Some("localhost" | "127.0.0.1" | "::1")) => {
367 return Err(ConfigError::Validation(format!(
368 "[[llm.providers]] entry '{name}': cocoon_client_url host must be \
369 localhost or 127.0.0.1, got '{}'",
370 u.host_str().unwrap_or("<none>")
371 )));
372 }
373 Ok(u) if u.scheme() != "http" && u.scheme() != "https" => {
374 return Err(ConfigError::Validation(format!(
375 "[[llm.providers]] entry '{name}': cocoon_client_url \
376 scheme must be http or https, got '{}'",
377 u.scheme()
378 )));
379 }
380 _ => {}
381 }
382 }
383 if self.model.as_deref().is_some_and(|m| m.trim().is_empty()) {
384 return Err(ConfigError::Validation(format!(
385 "[[llm.providers]] entry '{name}': model must not be empty \
386 for cocoon provider"
387 )));
388 }
389 if let Some(ref p) = self.cocoon_pricing {
390 if !p.prompt_cents_per_1k.is_finite() || p.prompt_cents_per_1k < 0.0 {
391 return Err(ConfigError::Validation(format!(
392 "[[llm.providers]] entry '{name}': cocoon_pricing.prompt_cents_per_1k \
393 must be a finite non-negative number"
394 )));
395 }
396 if !p.completion_cents_per_1k.is_finite() || p.completion_cents_per_1k < 0.0 {
397 return Err(ConfigError::Validation(format!(
398 "[[llm.providers]] entry '{name}': \
399 cocoon_pricing.completion_cents_per_1k \
400 must be a finite non-negative number"
401 )));
402 }
403 }
404 }
405
406 self.warn_irrelevant_fields();
408
409 if self.stt_model.is_some() && self.provider_type == ProviderKind::Ollama {
412 tracing::warn!(
413 provider = self.effective_name(),
414 "field `stt_model` is set on an Ollama provider; Ollama does not support the \
415 Whisper STT API — use OpenAI, compatible, or candle instead"
416 );
417 }
418
419 Ok(())
420 }
421
422 #[must_use]
424 pub fn effective_gonka_chain_prefix(&self) -> &str {
425 self.gonka_chain_prefix.as_deref().unwrap_or("gonka")
426 }
427
428 fn warn_irrelevant_fields(&self) {
429 let name = self.effective_name();
430 match self.provider_type {
431 ProviderKind::Ollama => {
432 if self.thinking.is_some() {
433 tracing::warn!(
434 provider = name,
435 "field `thinking` is only used by Claude providers"
436 );
437 }
438 if self.reasoning_effort.is_some() {
439 tracing::warn!(
440 provider = name,
441 "field `reasoning_effort` is only used by OpenAI providers"
442 );
443 }
444 if self.thinking_level.is_some() || self.thinking_budget.is_some() {
445 tracing::warn!(
446 provider = name,
447 "fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
448 );
449 }
450 }
451 ProviderKind::Claude => {
452 if self.reasoning_effort.is_some() {
453 tracing::warn!(
454 provider = name,
455 "field `reasoning_effort` is only used by OpenAI providers"
456 );
457 }
458 if self.thinking_level.is_some() || self.thinking_budget.is_some() {
459 tracing::warn!(
460 provider = name,
461 "fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
462 );
463 }
464 }
465 ProviderKind::OpenAi => {
466 if self.thinking.is_some() {
467 tracing::warn!(
468 provider = name,
469 "field `thinking` is only used by Claude providers"
470 );
471 }
472 if self.thinking_level.is_some() || self.thinking_budget.is_some() {
473 tracing::warn!(
474 provider = name,
475 "fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
476 );
477 }
478 }
479 ProviderKind::Gemini => {
480 if self.thinking.is_some() {
481 tracing::warn!(
482 provider = name,
483 "field `thinking` is only used by Claude providers"
484 );
485 }
486 if self.reasoning_effort.is_some() {
487 tracing::warn!(
488 provider = name,
489 "field `reasoning_effort` is only used by OpenAI providers"
490 );
491 }
492 }
493 ProviderKind::Gonka => {
494 if self.thinking.is_some() {
495 tracing::warn!(
496 provider = name,
497 "field `thinking` is only used by Claude providers"
498 );
499 }
500 if self.reasoning_effort.is_some() {
501 tracing::warn!(
502 provider = name,
503 "field `reasoning_effort` is only used by OpenAI providers"
504 );
505 }
506 if self.thinking_level.is_some() || self.thinking_budget.is_some() {
507 tracing::warn!(
508 provider = name,
509 "fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
510 );
511 }
512 }
513 ProviderKind::Compatible | ProviderKind::Candle => {}
514 ProviderKind::Cocoon => {
515 if self.base_url.is_some() {
516 tracing::warn!(
517 provider = name,
518 "field `base_url` is ignored for cocoon providers; use `cocoon_client_url` instead"
519 );
520 }
521 }
522 }
523 }
524
525 fn validate_gonka_nodes(&self) -> Result<(), crate::error::ConfigError> {
526 use crate::error::ConfigError;
527 if self.gonka_nodes.is_empty() {
528 return Err(ConfigError::Validation(format!(
529 "[[llm.providers]] entry '{}' with type=\"gonka\" must set non-empty `gonka_nodes`",
530 self.effective_name()
531 )));
532 }
533 for (i, node) in self.gonka_nodes.iter().enumerate() {
534 if node.url.is_empty() {
535 return Err(ConfigError::Validation(format!(
536 "[[llm.providers]] entry '{}' gonka_nodes[{i}].url must not be empty",
537 self.effective_name()
538 )));
539 }
540 if !node.url.starts_with("http://") && !node.url.starts_with("https://") {
541 return Err(ConfigError::Validation(format!(
542 "[[llm.providers]] entry '{}' gonka_nodes[{i}].url must start with http:// or https://",
543 self.effective_name()
544 )));
545 }
546 }
547 Ok(())
548 }
549}
550
551#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
575#[serde(default)]
576pub struct ProviderOverrides {
577 #[serde(skip_serializing_if = "Option::is_none")]
579 pub reasoning_effort: Option<String>,
580}
581
582impl ProviderOverrides {
583 #[must_use]
596 pub fn is_empty(&self) -> bool {
597 self.reasoning_effort.is_none()
598 }
599}
600
601#[must_use = "validation result must be checked"]
611pub fn validate_pool(entries: &[ProviderEntry]) -> Result<(), crate::error::ConfigError> {
612 use crate::error::ConfigError;
613 use std::collections::HashSet;
614
615 if entries.is_empty() {
616 return Err(ConfigError::Validation(
617 "at least one LLM provider must be configured in [[llm.providers]]".into(),
618 ));
619 }
620
621 let default_count = entries.iter().filter(|e| e.default).count();
622 if default_count > 1 {
623 return Err(ConfigError::Validation(
624 "only one [[llm.providers]] entry can be marked `default = true`".into(),
625 ));
626 }
627
628 let mut seen_names: HashSet<String> = HashSet::new();
629 for entry in entries {
630 let name = entry.effective_name();
631 if !seen_names.insert(name.clone()) {
632 return Err(ConfigError::Validation(format!(
633 "duplicate provider name \"{name}\" in [[llm.providers]]"
634 )));
635 }
636 entry.validate()?;
637 }
638
639 Ok(())
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use crate::ProviderKind;
646
647 #[test]
648 fn provider_entry_debug_redacts_api_key() {
649 let entry = ProviderEntry {
650 api_key: Some("sk-SUPERSECRET".to_owned()),
651 ..ProviderEntry::default()
652 };
653 let dbg = format!("{entry:?}");
654 assert!(!dbg.contains("sk-SUPERSECRET"));
655 assert!(dbg.contains("[REDACTED]"));
656 }
657
658 #[test]
659 fn provider_entry_debug_none_api_key() {
660 let entry = ProviderEntry::default();
661 let dbg = format!("{entry:?}");
662 assert!(!dbg.contains("[REDACTED]"));
663 assert!(dbg.contains("api_key: None"));
664 }
665
666 #[test]
667 fn provider_entry_debug_redacts_nested_candle_hf_token() {
668 let entry = ProviderEntry {
669 provider_type: ProviderKind::Candle,
670 candle: Some(super::super::CandleInlineConfig {
671 hf_token: Some("hf_SUPERSECRET".to_owned()),
672 ..super::super::CandleInlineConfig::default()
673 }),
674 ..ProviderEntry::default()
675 };
676 let dbg = format!("{entry:?}");
677 assert!(!dbg.contains("hf_SUPERSECRET"));
678 assert!(dbg.contains("[REDACTED]"));
679 }
680}