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}