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
142    // --- Gonka-specific ---
143    /// Gonka network node pool. Required (non-empty) when `type = "gonka"`.
144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
145    pub gonka_nodes: Vec<GonkaNode>,
146    /// bech32 chain prefix for address encoding. Defaults to `"gonka"` when omitted.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub gonka_chain_prefix: Option<String>,
149
150    // --- Cocoon-specific ---
151    /// Cocoon sidecar HTTP URL. Defaults to `"http://localhost:10000"` when absent.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub cocoon_client_url: Option<String>,
154    /// Sentinel field for access hash. Leave empty in config; actual value
155    /// is resolved from the age vault as `ZEPH_COCOON_ACCESS_HASH`.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub cocoon_access_hash: Option<String>,
158    /// Whether to perform a health check against `/stats` at provider construction time.
159    #[serde(default = "default_true", skip_serializing_if = "is_true")]
160    pub cocoon_health_check: bool,
161    /// Manual per-1K-token pricing for this Cocoon provider.
162    ///
163    /// Cocoon model names (e.g. `Qwen/Qwen3-0.6B`) are not in the built-in pricing table.
164    /// When this section is present, the values are registered with `CostTracker` at startup
165    /// so that token costs are tracked accurately.
166    ///
167    /// Example TOML:
168    /// ```toml
169    /// [llm.providers.cocoon_pricing]
170    /// prompt_cents_per_1k = 0.01
171    /// completion_cents_per_1k = 0.03
172    /// ```
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub cocoon_pricing: Option<CocoonPricing>,
175
176    /// Provider-specific instruction file.
177    #[serde(default)]
178    pub instruction_file: Option<std::path::PathBuf>,
179
180    /// Maximum concurrent LLM calls from orchestrated sub-agents to this provider.
181    ///
182    /// When set, `DagScheduler` acquires a semaphore permit before dispatching a
183    /// sub-agent that targets this provider. Dispatch is deferred (using the existing
184    /// `deferral_backoff` mechanism) when the semaphore is saturated.
185    ///
186    /// `None` (default) = unlimited — no admission control applied.
187    ///
188    /// # Example (TOML)
189    ///
190    /// ```toml
191    /// [[llm.providers]]
192    /// name = "quality"
193    /// type = "openai"
194    /// model = "gpt-5"
195    /// max_concurrent = 3
196    /// ```
197    #[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    /// Resolve the effective name: explicit `name` field or type string.
278    #[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    /// Resolve the effective model: explicit `model` field or the provider-type default.
286    ///
287    /// Defaults mirror those used in `build_provider_from_entry` so that `runtime.model_name`
288    /// always reflects the actual model being used rather than the provider type string.
289    #[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            // Compatible/Candle return empty because the model is resolved elsewhere.
300            // Gonka returns empty because it is a blockchain provider, not an LLM — there is no model concept.
301            ProviderKind::Compatible | ProviderKind::Candle | ProviderKind::Gonka => String::new(),
302            ProviderKind::Cocoon => "Qwen/Qwen3-0.6B".to_owned(),
303        }
304    }
305
306    /// Validate this entry for cross-field consistency.
307    ///
308    /// # Errors
309    ///
310    /// Returns `ConfigError` when a fatal invariant is violated (e.g. compatible provider
311    /// without a name).
312    #[must_use = "validation result must be checked"]
313    pub fn validate(&self) -> Result<(), crate::error::ConfigError> {
314        use crate::error::ConfigError;
315
316        // B2: compatible provider MUST have name set.
317        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        // B3: gonka provider MUST have name and valid gonka_nodes.
324        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        // B4: cocoon provider MUST have a name.
334        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        // B5: cocoon URL must be valid http/https; cocoon model must not be empty.
343        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        // B1: warn on irrelevant fields.
395        self.warn_irrelevant_fields();
396
397        // W6: Candle STT-only provider (stt_model set, no model) is valid — no warning needed.
398        // Warn if Ollama has stt_model set (Ollama does not support Whisper API).
399        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    /// Resolve the effective Gonka chain prefix: explicit value or `"gonka"` default.
411    #[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/// Per-session LLM generation override parameters persisted across restarts (#4654).
540///
541/// Phase 1 captures `reasoning_effort` only. Serialized to JSON and stored in the
542/// `channel_preferences` table under `pref_key = "provider_overrides"`.
543///
544/// `#[serde(default)]` makes deserialization forward-compatible: a blob written by a newer
545/// binary with additional fields is accepted, unknown fields are ignored, so the known params
546/// still apply. (Issue #4654 originally specified `deny_unknown_fields`; this was intentionally
547/// relaxed for forward compatibility — see PR and CHANGELOG.)
548///
549/// # Examples
550///
551/// ```
552/// use zeph_config::ProviderOverrides;
553///
554/// let overrides = ProviderOverrides {
555///     reasoning_effort: Some("high".to_owned()),
556/// };
557/// assert!(!overrides.is_empty());
558///
559/// let empty = ProviderOverrides::default();
560/// assert!(empty.is_empty());
561/// ```
562#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
563#[serde(default)]
564pub struct ProviderOverrides {
565    /// `OpenAI` reasoning effort: `"low"`, `"medium"`, or `"high"`. `None` = provider default.
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub reasoning_effort: Option<String>,
568}
569
570impl ProviderOverrides {
571    /// Returns `true` when no override is set.
572    ///
573    /// Used by the persistence layer to skip writing an empty blob.
574    ///
575    /// # Examples
576    ///
577    /// ```
578    /// use zeph_config::ProviderOverrides;
579    ///
580    /// assert!(ProviderOverrides::default().is_empty());
581    /// assert!(!ProviderOverrides { reasoning_effort: Some("low".into()) }.is_empty());
582    /// ```
583    #[must_use]
584    pub fn is_empty(&self) -> bool {
585        self.reasoning_effort.is_none()
586    }
587}
588
589/// Validate a pool of `ProviderEntry` items.
590///
591/// # Errors
592///
593/// Returns `ConfigError` for fatal validation failures:
594/// - Empty pool
595/// - Duplicate names
596/// - Multiple entries marked `default = true`
597/// - Individual entry validation errors
598#[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}