Skip to main content

zeph_config/providers/
entry.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Unified provider entry and pool validation.
5//!
6//! [`ProviderEntry`] is the flat-union struct deserialized from each `[[llm.providers]]`
7//! table; [`validate_pool`] enforces cross-entry invariants (unique names, single
8//! default). Also holds the provider-specific sub-structs [`GonkaNode`],
9//! [`CocoonPricing`], and the per-session [`ProviderOverrides`].
10
11use serde::{Deserialize, Serialize};
12
13use super::{
14    CacheTtl, CandleInlineConfig, GeminiThinkingLevel, ProviderKind, ThinkingConfig, default_true,
15    is_true,
16};
17
18/// A single Gonka network node endpoint.
19///
20/// Used in `[[llm.providers]]` entries with `type = "gonka"` to declare
21/// the node pool for blockchain inference routing.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
23pub struct GonkaNode {
24    /// HTTP(S) URL of the Gonka node (e.g. `"https://node1.gonka.ai"`).
25    pub url: String,
26    /// On-chain bech32 address of this node (e.g. `"gonka1w508d6qejxtdg4y5r3zarvary0c5xw7k2gsyg6"`).
27    ///
28    /// Required for signature construction: every signed request binds to the target node's
29    /// on-chain address, making signatures non-replayable across different nodes.
30    pub address: String,
31    /// Optional human-readable label for `zeph gonka doctor` output.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub name: Option<String>,
34}
35/// Per-1K-token pricing for a Cocoon provider, in cents.
36///
37/// Cocoon model names (e.g. `Qwen/Qwen3-0.6B`) are not in the built-in pricing table.
38/// When this struct is present in a provider entry, its values are registered with
39/// `CostTracker` at startup so that token costs are tracked accurately.
40///
41/// Reasoning tokens (when the model uses chain-of-thought) are folded into
42/// `completion_tokens` by the Cocoon sidecar and counted at the completion price.
43#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
44pub struct CocoonPricing {
45    /// Prompt (input) token price in cents per 1K tokens.
46    #[serde(default)]
47    pub prompt_cents_per_1k: f64,
48    /// Completion (output) token price in cents per 1K tokens.
49    /// Reasoning tokens are counted here since the sidecar folds them into completion tokens.
50    #[serde(default)]
51    pub completion_cents_per_1k: f64,
52}
53
54/// Unified provider entry: one struct replaces `CloudLlmConfig`, `OpenAiConfig`,
55/// `GeminiConfig`, `OllamaConfig`, `CompatibleConfig`, and `OrchestratorProviderConfig`.
56///
57/// Provider-specific fields use `#[serde(default)]` and are ignored by backends
58/// that do not use them (flat-union pattern).
59#[derive(Clone, Deserialize, Serialize)]
60#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
61pub struct ProviderEntry {
62    /// Required: provider backend type.
63    #[serde(rename = "type")]
64    pub provider_type: ProviderKind,
65
66    /// Optional name for multi-provider configs. Auto-generated from type if absent.
67    #[serde(default)]
68    pub name: Option<String>,
69
70    /// Model identifier. Required for most types.
71    #[serde(default)]
72    pub model: Option<String>,
73
74    /// API base URL. Each type has its own default.
75    #[serde(default)]
76    pub base_url: Option<String>,
77
78    /// Max output tokens.
79    #[serde(default)]
80    pub max_tokens: Option<u32>,
81
82    /// Embedding model. When set, this provider supports `embed()` calls.
83    #[serde(default)]
84    pub embedding_model: Option<String>,
85
86    /// STT model. When set, this provider supports speech-to-text via the Whisper API or
87    /// Candle-local inference.
88    #[serde(default)]
89    pub stt_model: Option<String>,
90
91    /// Optional SHA-256 hex digest of the Candle-local Whisper model safetensors file.
92    ///
93    /// Only consulted when `provider_type = "candle"`. When set, the file is verified
94    /// before loading; mismatch aborts startup with an error.
95    #[serde(default)]
96    pub stt_model_sha256: Option<String>,
97
98    /// Mark this entry as the embedding provider (handles `embed()` calls).
99    #[serde(default)]
100    pub embed: bool,
101
102    /// Mark this entry as the default chat provider (overrides position-based default).
103    #[serde(default)]
104    pub default: bool,
105
106    // --- Claude-specific ---
107    #[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    /// Prompt cache TTL variant. `None` keeps the default ~5-minute ephemeral TTL.
114    /// Set to `"1h"` to enable the extended 1-hour TTL (beta, ~2× write cost).
115    #[serde(default)]
116    pub prompt_cache_ttl: Option<CacheTtl>,
117
118    // --- OpenAI-specific ---
119    #[serde(default)]
120    pub reasoning_effort: Option<String>,
121
122    // --- Gemini-specific ---
123    #[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    // --- Compatible-specific: optional inline api_key ---
131    #[serde(default)]
132    pub api_key: Option<String>,
133
134    // --- Candle-specific ---
135    #[serde(default)]
136    pub candle: Option<CandleInlineConfig>,
137
138    // --- Vision ---
139    #[serde(default)]
140    pub vision_model: Option<String>,
141    /// Explicit vision-capability override for `type = "openai"` / `type = "compatible"`
142    /// providers.
143    ///
144    /// `None` (default) auto-detects via the built-in `OpenAI` model-name prefix table
145    /// (e.g. `gpt-4o` → capable, `gpt-3.5-turbo` → not capable), which fails safe to `false`
146    /// for any unrecognised model name — the common case for arbitrary `compatible` endpoints.
147    /// Set explicitly to override the auto-detected value in either direction. Ignored by
148    /// other provider types.
149    #[serde(default)]
150    pub vision: Option<bool>,
151
152    // --- Gonka-specific ---
153    /// Gonka network node pool. Required (non-empty) when `type = "gonka"`.
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub gonka_nodes: Vec<GonkaNode>,
156    /// bech32 chain prefix for address encoding. Defaults to `"gonka"` when omitted.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub gonka_chain_prefix: Option<String>,
159
160    // --- Cocoon-specific ---
161    /// Cocoon sidecar HTTP URL. Defaults to `"http://localhost:10000"` when absent.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub cocoon_client_url: Option<String>,
164    /// Sentinel field for access hash. Leave empty in config; actual value
165    /// is resolved from the age vault as `ZEPH_COCOON_ACCESS_HASH`.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub cocoon_access_hash: Option<String>,
168    /// Whether to perform a health check against `/stats` at provider construction time.
169    #[serde(default = "default_true", skip_serializing_if = "is_true")]
170    pub cocoon_health_check: bool,
171    /// Manual per-1K-token pricing for this Cocoon provider.
172    ///
173    /// Cocoon model names (e.g. `Qwen/Qwen3-0.6B`) are not in the built-in pricing table.
174    /// When this section is present, the values are registered with `CostTracker` at startup
175    /// so that token costs are tracked accurately.
176    ///
177    /// Example TOML:
178    /// ```toml
179    /// [llm.providers.cocoon_pricing]
180    /// prompt_cents_per_1k = 0.01
181    /// completion_cents_per_1k = 0.03
182    /// ```
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub cocoon_pricing: Option<CocoonPricing>,
185
186    /// Provider-specific instruction file.
187    #[serde(default)]
188    pub instruction_file: Option<std::path::PathBuf>,
189
190    /// Maximum concurrent LLM calls from orchestrated sub-agents to this provider.
191    ///
192    /// When set, `DagScheduler` acquires a semaphore permit before dispatching a
193    /// sub-agent that targets this provider. Dispatch is deferred (using the existing
194    /// `deferral_backoff` mechanism) when the semaphore is saturated.
195    ///
196    /// `None` (default) = unlimited — no admission control applied.
197    ///
198    /// # Example (TOML)
199    ///
200    /// ```toml
201    /// [[llm.providers]]
202    /// name = "quality"
203    /// type = "openai"
204    /// model = "gpt-5"
205    /// max_concurrent = 3
206    /// ```
207    #[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    /// Resolve the effective name: explicit `name` field or type string.
290    #[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    /// Resolve the effective model: explicit `model` field or the provider-type default.
298    ///
299    /// Defaults mirror those used in `build_provider_from_entry` so that `runtime.model_name`
300    /// always reflects the actual model being used rather than the provider type string.
301    #[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            // Compatible/Candle return empty because the model is resolved elsewhere.
312            // Gonka returns empty because it is a blockchain provider, not an LLM — there is no model concept.
313            ProviderKind::Compatible | ProviderKind::Candle | ProviderKind::Gonka => String::new(),
314            ProviderKind::Cocoon => "Qwen/Qwen3-0.6B".to_owned(),
315        }
316    }
317
318    /// Validate this entry for cross-field consistency.
319    ///
320    /// # Errors
321    ///
322    /// Returns `ConfigError` when a fatal invariant is violated (e.g. compatible provider
323    /// without a name).
324    #[must_use = "validation result must be checked"]
325    pub fn validate(&self) -> Result<(), crate::error::ConfigError> {
326        use crate::error::ConfigError;
327
328        // B2: compatible provider MUST have name set.
329        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        // B3: gonka provider MUST have name and valid gonka_nodes.
336        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        // B4: cocoon provider MUST have a name.
346        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        // B5: cocoon URL must be valid http/https; cocoon model must not be empty.
355        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        // B1: warn on irrelevant fields.
407        self.warn_irrelevant_fields();
408
409        // W6: Candle STT-only provider (stt_model set, no model) is valid — no warning needed.
410        // Warn if Ollama has stt_model set (Ollama does not support Whisper API).
411        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    /// Resolve the effective Gonka chain prefix: explicit value or `"gonka"` default.
423    #[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/// Per-session LLM generation override parameters persisted across restarts (#4654).
552///
553/// Phase 1 captures `reasoning_effort` only. Serialized to JSON and stored in the
554/// `channel_preferences` table under `pref_key = "provider_overrides"`.
555///
556/// `#[serde(default)]` makes deserialization forward-compatible: a blob written by a newer
557/// binary with additional fields is accepted, unknown fields are ignored, so the known params
558/// still apply. (Issue #4654 originally specified `deny_unknown_fields`; this was intentionally
559/// relaxed for forward compatibility — see PR and CHANGELOG.)
560///
561/// # Examples
562///
563/// ```
564/// use zeph_config::ProviderOverrides;
565///
566/// let overrides = ProviderOverrides {
567///     reasoning_effort: Some("high".to_owned()),
568/// };
569/// assert!(!overrides.is_empty());
570///
571/// let empty = ProviderOverrides::default();
572/// assert!(empty.is_empty());
573/// ```
574#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
575#[serde(default)]
576pub struct ProviderOverrides {
577    /// `OpenAI` reasoning effort: `"low"`, `"medium"`, or `"high"`. `None` = provider default.
578    #[serde(skip_serializing_if = "Option::is_none")]
579    pub reasoning_effort: Option<String>,
580}
581
582impl ProviderOverrides {
583    /// Returns `true` when no override is set.
584    ///
585    /// Used by the persistence layer to skip writing an empty blob.
586    ///
587    /// # Examples
588    ///
589    /// ```
590    /// use zeph_config::ProviderOverrides;
591    ///
592    /// assert!(ProviderOverrides::default().is_empty());
593    /// assert!(!ProviderOverrides { reasoning_effort: Some("low".into()) }.is_empty());
594    /// ```
595    #[must_use]
596    pub fn is_empty(&self) -> bool {
597        self.reasoning_effort.is_none()
598    }
599}
600
601/// Validate a pool of `ProviderEntry` items.
602///
603/// # Errors
604///
605/// Returns `ConfigError` for fatal validation failures:
606/// - Empty pool
607/// - Duplicate names
608/// - Multiple entries marked `default = true`
609/// - Individual entry validation errors
610#[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}