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}