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(Debug, 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 ProviderEntry {
238    /// Resolve the effective name: explicit `name` field or type string.
239    #[must_use]
240    pub fn effective_name(&self) -> String {
241        self.name
242            .clone()
243            .unwrap_or_else(|| self.provider_type.as_str().to_owned())
244    }
245
246    /// Resolve the effective model: explicit `model` field or the provider-type default.
247    ///
248    /// Defaults mirror those used in `build_provider_from_entry` so that `runtime.model_name`
249    /// always reflects the actual model being used rather than the provider type string.
250    #[must_use]
251    pub fn effective_model(&self) -> String {
252        if let Some(ref m) = self.model {
253            return m.clone();
254        }
255        match self.provider_type {
256            ProviderKind::Ollama => "qwen3:8b".to_owned(),
257            ProviderKind::Claude => "claude-haiku-4-5-20251001".to_owned(),
258            ProviderKind::OpenAi => "gpt-4o-mini".to_owned(),
259            ProviderKind::Gemini => "gemini-2.0-flash".to_owned(),
260            // Compatible/Candle return empty because the model is resolved elsewhere.
261            // Gonka returns empty because it is a blockchain provider, not an LLM — there is no model concept.
262            ProviderKind::Compatible | ProviderKind::Candle | ProviderKind::Gonka => String::new(),
263            ProviderKind::Cocoon => "Qwen/Qwen3-0.6B".to_owned(),
264        }
265    }
266
267    /// Validate this entry for cross-field consistency.
268    ///
269    /// # Errors
270    ///
271    /// Returns `ConfigError` when a fatal invariant is violated (e.g. compatible provider
272    /// without a name).
273    #[must_use = "validation result must be checked"]
274    pub fn validate(&self) -> Result<(), crate::error::ConfigError> {
275        use crate::error::ConfigError;
276
277        // B2: compatible provider MUST have name set.
278        if self.provider_type == ProviderKind::Compatible && self.name.is_none() {
279            return Err(ConfigError::Validation(
280                "[[llm.providers]] entry with type=\"compatible\" must set `name`".into(),
281            ));
282        }
283
284        // B3: gonka provider MUST have name and valid gonka_nodes.
285        if self.provider_type == ProviderKind::Gonka {
286            if self.name.is_none() {
287                return Err(ConfigError::Validation(
288                    "[[llm.providers]] entry with type=\"gonka\" must set `name`".into(),
289                ));
290            }
291            self.validate_gonka_nodes()?;
292        }
293
294        // B4: cocoon provider MUST have a name.
295        if self.provider_type == ProviderKind::Cocoon
296            && self.name.as_ref().is_none_or(String::is_empty)
297        {
298            return Err(ConfigError::Validation(
299                "[[llm.providers]] entry with type=\"cocoon\" must set `name`".into(),
300            ));
301        }
302
303        // B5: cocoon URL must be valid http/https; cocoon model must not be empty.
304        if self.provider_type == ProviderKind::Cocoon {
305            let name = self.effective_name();
306            if let Some(ref url_str) = self.cocoon_client_url {
307                match url::Url::parse(url_str) {
308                    Err(_) => {
309                        return Err(ConfigError::Validation(format!(
310                            "[[llm.providers]] entry '{name}': cocoon_client_url \
311                             '{url_str}' is not a valid URL; expected format: \
312                             http://localhost:10000"
313                        )));
314                    }
315                    Ok(u) if !matches!(u.host_str(), Some("localhost" | "127.0.0.1" | "::1")) => {
316                        return Err(ConfigError::Validation(format!(
317                            "[[llm.providers]] entry '{name}': cocoon_client_url host must be \
318                             localhost or 127.0.0.1, got '{}'",
319                            u.host_str().unwrap_or("<none>")
320                        )));
321                    }
322                    Ok(u) if u.scheme() != "http" && u.scheme() != "https" => {
323                        return Err(ConfigError::Validation(format!(
324                            "[[llm.providers]] entry '{name}': cocoon_client_url \
325                             scheme must be http or https, got '{}'",
326                            u.scheme()
327                        )));
328                    }
329                    _ => {}
330                }
331            }
332            if self.model.as_deref().is_some_and(|m| m.trim().is_empty()) {
333                return Err(ConfigError::Validation(format!(
334                    "[[llm.providers]] entry '{name}': model must not be empty \
335                     for cocoon provider"
336                )));
337            }
338            if let Some(ref p) = self.cocoon_pricing {
339                if !p.prompt_cents_per_1k.is_finite() || p.prompt_cents_per_1k < 0.0 {
340                    return Err(ConfigError::Validation(format!(
341                        "[[llm.providers]] entry '{name}': cocoon_pricing.prompt_cents_per_1k \
342                         must be a finite non-negative number"
343                    )));
344                }
345                if !p.completion_cents_per_1k.is_finite() || p.completion_cents_per_1k < 0.0 {
346                    return Err(ConfigError::Validation(format!(
347                        "[[llm.providers]] entry '{name}': \
348                         cocoon_pricing.completion_cents_per_1k \
349                         must be a finite non-negative number"
350                    )));
351                }
352            }
353        }
354
355        // B1: warn on irrelevant fields.
356        self.warn_irrelevant_fields();
357
358        // W6: Candle STT-only provider (stt_model set, no model) is valid — no warning needed.
359        // Warn if Ollama has stt_model set (Ollama does not support Whisper API).
360        if self.stt_model.is_some() && self.provider_type == ProviderKind::Ollama {
361            tracing::warn!(
362                provider = self.effective_name(),
363                "field `stt_model` is set on an Ollama provider; Ollama does not support the \
364                 Whisper STT API — use OpenAI, compatible, or candle instead"
365            );
366        }
367
368        Ok(())
369    }
370
371    /// Resolve the effective Gonka chain prefix: explicit value or `"gonka"` default.
372    #[must_use]
373    pub fn effective_gonka_chain_prefix(&self) -> &str {
374        self.gonka_chain_prefix.as_deref().unwrap_or("gonka")
375    }
376
377    fn warn_irrelevant_fields(&self) {
378        let name = self.effective_name();
379        match self.provider_type {
380            ProviderKind::Ollama => {
381                if self.thinking.is_some() {
382                    tracing::warn!(
383                        provider = name,
384                        "field `thinking` is only used by Claude providers"
385                    );
386                }
387                if self.reasoning_effort.is_some() {
388                    tracing::warn!(
389                        provider = name,
390                        "field `reasoning_effort` is only used by OpenAI providers"
391                    );
392                }
393                if self.thinking_level.is_some() || self.thinking_budget.is_some() {
394                    tracing::warn!(
395                        provider = name,
396                        "fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
397                    );
398                }
399            }
400            ProviderKind::Claude => {
401                if self.reasoning_effort.is_some() {
402                    tracing::warn!(
403                        provider = name,
404                        "field `reasoning_effort` is only used by OpenAI providers"
405                    );
406                }
407                if self.thinking_level.is_some() || self.thinking_budget.is_some() {
408                    tracing::warn!(
409                        provider = name,
410                        "fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
411                    );
412                }
413            }
414            ProviderKind::OpenAi => {
415                if self.thinking.is_some() {
416                    tracing::warn!(
417                        provider = name,
418                        "field `thinking` is only used by Claude providers"
419                    );
420                }
421                if self.thinking_level.is_some() || self.thinking_budget.is_some() {
422                    tracing::warn!(
423                        provider = name,
424                        "fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
425                    );
426                }
427            }
428            ProviderKind::Gemini => {
429                if self.thinking.is_some() {
430                    tracing::warn!(
431                        provider = name,
432                        "field `thinking` is only used by Claude providers"
433                    );
434                }
435                if self.reasoning_effort.is_some() {
436                    tracing::warn!(
437                        provider = name,
438                        "field `reasoning_effort` is only used by OpenAI providers"
439                    );
440                }
441            }
442            ProviderKind::Gonka => {
443                if self.thinking.is_some() {
444                    tracing::warn!(
445                        provider = name,
446                        "field `thinking` is only used by Claude providers"
447                    );
448                }
449                if self.reasoning_effort.is_some() {
450                    tracing::warn!(
451                        provider = name,
452                        "field `reasoning_effort` is only used by OpenAI providers"
453                    );
454                }
455                if self.thinking_level.is_some() || self.thinking_budget.is_some() {
456                    tracing::warn!(
457                        provider = name,
458                        "fields `thinking_level`/`thinking_budget` are only used by Gemini providers"
459                    );
460                }
461            }
462            ProviderKind::Compatible | ProviderKind::Candle => {}
463            ProviderKind::Cocoon => {
464                if self.base_url.is_some() {
465                    tracing::warn!(
466                        provider = name,
467                        "field `base_url` is ignored for cocoon providers; use `cocoon_client_url` instead"
468                    );
469                }
470            }
471        }
472    }
473
474    fn validate_gonka_nodes(&self) -> Result<(), crate::error::ConfigError> {
475        use crate::error::ConfigError;
476        if self.gonka_nodes.is_empty() {
477            return Err(ConfigError::Validation(format!(
478                "[[llm.providers]] entry '{}' with type=\"gonka\" must set non-empty `gonka_nodes`",
479                self.effective_name()
480            )));
481        }
482        for (i, node) in self.gonka_nodes.iter().enumerate() {
483            if node.url.is_empty() {
484                return Err(ConfigError::Validation(format!(
485                    "[[llm.providers]] entry '{}' gonka_nodes[{i}].url must not be empty",
486                    self.effective_name()
487                )));
488            }
489            if !node.url.starts_with("http://") && !node.url.starts_with("https://") {
490                return Err(ConfigError::Validation(format!(
491                    "[[llm.providers]] entry '{}' gonka_nodes[{i}].url must start with http:// or https://",
492                    self.effective_name()
493                )));
494            }
495        }
496        Ok(())
497    }
498}
499
500/// Per-session LLM generation override parameters persisted across restarts (#4654).
501///
502/// Phase 1 captures `reasoning_effort` only. Serialized to JSON and stored in the
503/// `channel_preferences` table under `pref_key = "provider_overrides"`.
504///
505/// `#[serde(default)]` makes deserialization forward-compatible: a blob written by a newer
506/// binary with additional fields is accepted, unknown fields are ignored, so the known params
507/// still apply. (Issue #4654 originally specified `deny_unknown_fields`; this was intentionally
508/// relaxed for forward compatibility — see PR and CHANGELOG.)
509///
510/// # Examples
511///
512/// ```
513/// use zeph_config::ProviderOverrides;
514///
515/// let overrides = ProviderOverrides {
516///     reasoning_effort: Some("high".to_owned()),
517/// };
518/// assert!(!overrides.is_empty());
519///
520/// let empty = ProviderOverrides::default();
521/// assert!(empty.is_empty());
522/// ```
523#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
524#[serde(default)]
525pub struct ProviderOverrides {
526    /// `OpenAI` reasoning effort: `"low"`, `"medium"`, or `"high"`. `None` = provider default.
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub reasoning_effort: Option<String>,
529}
530
531impl ProviderOverrides {
532    /// Returns `true` when no override is set.
533    ///
534    /// Used by the persistence layer to skip writing an empty blob.
535    ///
536    /// # Examples
537    ///
538    /// ```
539    /// use zeph_config::ProviderOverrides;
540    ///
541    /// assert!(ProviderOverrides::default().is_empty());
542    /// assert!(!ProviderOverrides { reasoning_effort: Some("low".into()) }.is_empty());
543    /// ```
544    #[must_use]
545    pub fn is_empty(&self) -> bool {
546        self.reasoning_effort.is_none()
547    }
548}
549
550/// Validate a pool of `ProviderEntry` items.
551///
552/// # Errors
553///
554/// Returns `ConfigError` for fatal validation failures:
555/// - Empty pool
556/// - Duplicate names
557/// - Multiple entries marked `default = true`
558/// - Individual entry validation errors
559#[must_use = "validation result must be checked"]
560pub fn validate_pool(entries: &[ProviderEntry]) -> Result<(), crate::error::ConfigError> {
561    use crate::error::ConfigError;
562    use std::collections::HashSet;
563
564    if entries.is_empty() {
565        return Err(ConfigError::Validation(
566            "at least one LLM provider must be configured in [[llm.providers]]".into(),
567        ));
568    }
569
570    let default_count = entries.iter().filter(|e| e.default).count();
571    if default_count > 1 {
572        return Err(ConfigError::Validation(
573            "only one [[llm.providers]] entry can be marked `default = true`".into(),
574        ));
575    }
576
577    let mut seen_names: HashSet<String> = HashSet::new();
578    for entry in entries {
579        let name = entry.effective_name();
580        if !seen_names.insert(name.clone()) {
581            return Err(ConfigError::Validation(format!(
582                "duplicate provider name \"{name}\" in [[llm.providers]]"
583            )));
584        }
585        entry.validate()?;
586    }
587
588    Ok(())
589}