Skip to main content

leviath_cli/config/
providers.rs

1//! `[providers]` and per-model overrides: which endpoint a stage's model resolves
2//! to, and the credentials for it.
3//!
4//! `ProviderConfig` hand-writes its `Debug` so an API key cannot reach a log
5//! through a derived one - the redaction is the reason this is not a derive.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Provider configuration.
11///
12/// `Debug` is hand-written (see below) so the keys cannot be printed.
13#[derive(Clone, Default, Serialize, Deserialize)]
14pub struct ProviderConfig {
15    /// Anthropic API key
16    #[serde(default)]
17    pub anthropic_api_key: Option<String>,
18
19    /// OpenAI API key
20    #[serde(default)]
21    pub openai_api_key: Option<String>,
22
23    /// Google AI (Gemini) API key
24    #[serde(default)]
25    pub google_api_key: Option<String>,
26
27    /// Whether the Claude Code CLI transport is enabled.
28    ///
29    /// **Opt-in, and never selected for the user.** The CLI injects its own
30    /// context into every call - including the account email address on the
31    /// OAuth (subscription) path - which cannot be disabled. `lev setup` offers
32    /// it and defaults to declining, so a user who presses Enter through the
33    /// wizard ends up with it off.
34    #[serde(default)]
35    pub claude_code_enabled: bool,
36
37    /// Path to the `claude` executable. `None` resolves `claude` on `PATH`.
38    #[serde(default)]
39    pub claude_code_binary: Option<String>,
40
41    /// Reasoning effort for the Claude Code transport: `low` | `medium` |
42    /// `high` | `xhigh` | `max`.
43    ///
44    /// Always sent explicitly. Left to itself the CLI picks `high` with adaptive
45    /// thinking, spending output tokens and latency Leviath never asked for.
46    /// `None` uses [`leviath_providers::claude_code::DEFAULT_EFFORT`].
47    #[serde(default)]
48    pub claude_code_effort: Option<String>,
49
50    /// Prompt-cache lifetime for Anthropic: `"5m"` (default) or `"1h"`.
51    ///
52    /// The longer one costs more to write and needs a beta header, which is
53    /// sent for you. Worth it for a staged agent: stages routinely take longer
54    /// than five minutes, so a prefix cached at the start of a run is cold by
55    /// the time a later stage could have reused it.
56    #[serde(default)]
57    pub anthropic_cache_ttl: Option<leviath_providers::anthropic::CacheTtl>,
58
59    /// Host-wide failover chain, as `"provider/model"` entries, best first.
60    ///
61    /// Tried after a stage's own `models` list and the default model when the
62    /// provider in use stops answering (out of credits, rejected key). Entries
63    /// naming an unregistered provider are skipped, and a malformed entry is
64    /// ignored with a warning rather than failing the load.
65    ///
66    /// `provider/model` rather than a bare provider name because a failover
67    /// target needs a model to send; there is no sensible default per provider.
68    /// A blueprint that names one model has nowhere to go without this, which
69    /// is exactly how issue #201 took every agent down at once.
70    #[serde(default)]
71    pub fallback_order: Vec<String>,
72}
73
74/// Hand-written so the API keys can never be printed.
75///
76/// A `#[derive(Debug)]` here meant one `tracing::debug!(?config)` anywhere in
77/// the workspace - or one `dbg!`, or an `anyhow` context that formats a struct
78/// holding this - would put every provider key into the logs. Nothing did that
79/// today, which is exactly when it is cheap to foreclose: the type now cannot
80/// leak, so nobody has to remember not to.
81///
82/// Reports whether each key is *set*, which is what a debug line is actually
83/// asking, and mirrors the `RedactedConfig` the `/api/config` handler returns.
84impl std::fmt::Debug for ProviderConfig {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("ProviderConfig")
87            .field("anthropic_api_key", &redacted(&self.anthropic_api_key))
88            .field("openai_api_key", &redacted(&self.openai_api_key))
89            .field("google_api_key", &redacted(&self.google_api_key))
90            .field("claude_code_enabled", &self.claude_code_enabled)
91            .field("claude_code_binary", &self.claude_code_binary)
92            .field("claude_code_effort", &self.claude_code_effort)
93            .field("anthropic_cache_ttl", &self.anthropic_cache_ttl)
94            .field("fallback_order", &self.fallback_order)
95            .finish()
96    }
97}
98
99/// `"<set>"` or `"<unset>"` for an optional secret, for [`Debug`] output.
100fn redacted(value: &Option<String>) -> &'static str {
101    match value {
102        Some(_) => "<set>",
103        None => "<unset>",
104    }
105}
106
107/// Optional overrides for a Rhai script provider, from `[model_providers.<name>]`.
108///
109/// Every field is optional. Keys not recognized below flow into [`Self::extra`]
110/// and are forwarded to the script's `initialize(config)` alongside `base_url`
111/// and `api_key`.
112#[derive(Debug, Clone, Serialize, Deserialize, Default)]
113pub struct ModelProviderConfig {
114    /// Script filename stem or path. Defaults to `<name>.rhai` in the providers
115    /// directory (`~/.leviath/providers/`).
116    #[serde(default)]
117    pub script: Option<String>,
118
119    /// API key forwarded to the script as `config.api_key` (a script may instead
120    /// read its own environment variable).
121    #[serde(default)]
122    pub api_key: Option<String>,
123
124    /// Base URL forwarded to the script as `config.base_url`.
125    #[serde(default)]
126    pub base_url: Option<String>,
127
128    /// Rate limit enforced by the Rust wrapper (requests/tokens per minute).
129    #[serde(default)]
130    pub rate_limit: Option<leviath_providers::RateLimitConfig>,
131
132    /// Any additional keys, forwarded verbatim into the script's `initialize`.
133    #[serde(flatten)]
134    pub extra: HashMap<String, toml::Value>,
135}