Skip to main content

drep/config/
backend.rs

1//! Typed backend fields for one `[[llm]]` table.
2//!
3//! The parent module validates the raw TOML tree before forgetting whether a
4//! defaulted field was explicit. This module owns the typed values themselves.
5
6use serde::{Deserialize, Deserializer};
7use toml::Value;
8
9use super::ConfigError;
10
11/// Which implementation serves one provider entry.
12#[derive(Clone, Debug, Default, PartialEq, Eq)]
13pub enum BackendKind {
14    /// Direct HTTP through `open-agent-sdk`.
15    #[default]
16    Http,
17    /// The installed Codex CLI using its saved ChatGPT authentication.
18    Codex,
19    /// Retained only so a disabled entry remains inert; enabled entries reject it.
20    Unknown(String),
21}
22
23impl BackendKind {
24    /// Stable configuration and cache identity.
25    pub fn as_str(&self) -> &str {
26        match self {
27            Self::Http => "http",
28            Self::Codex => "codex",
29            Self::Unknown(value) => value,
30        }
31    }
32}
33
34impl<'de> Deserialize<'de> for BackendKind {
35    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
36    where
37        D: Deserializer<'de>,
38    {
39        let value = String::deserialize(deserializer)?;
40        Ok(match value.as_str() {
41            "http" => Self::Http,
42            "codex" => Self::Codex,
43            _ => Self::Unknown(value),
44        })
45    }
46}
47
48/// Reasoning effort accepted by the Codex CLI configuration contract.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub enum ReasoningEffort {
51    Minimal,
52    Low,
53    Medium,
54    High,
55    Xhigh,
56    /// Retained only so a disabled entry remains inert; enabled entries reject it.
57    Unknown(String),
58}
59
60impl ReasoningEffort {
61    pub fn as_str(&self) -> &str {
62        match self {
63            Self::Minimal => "minimal",
64            Self::Low => "low",
65            Self::Medium => "medium",
66            Self::High => "high",
67            Self::Xhigh => "xhigh",
68            Self::Unknown(value) => value,
69        }
70    }
71}
72
73impl<'de> Deserialize<'de> for ReasoningEffort {
74    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
75    where
76        D: Deserializer<'de>,
77    {
78        let value = String::deserialize(deserializer)?;
79        Ok(match value.as_str() {
80            "minimal" => Self::Minimal,
81            "low" => Self::Low,
82            "medium" => Self::Medium,
83            "high" => Self::High,
84            "xhigh" => Self::Xhigh,
85            _ => Self::Unknown(value),
86        })
87    }
88}
89
90/// One provider in the failover chain.
91#[derive(Deserialize)]
92#[serde(default)]
93pub struct LlmConfig {
94    pub enabled: bool,
95    pub backend: BackendKind,
96    pub endpoint: Option<String>,
97    pub model: Option<String>,
98    pub api_key: Option<String>,
99    /// An argv - never a shell line - whose trimmed stdout is the credential.
100    ///
101    /// Declared after `api_key` because the field order is the resolution order:
102    /// an explicit key wins, then this, then the per-machine store.
103    pub api_key_command: Option<Vec<String>>,
104    pub protocol: Option<String>,
105    pub reasoning_effort: Option<ReasoningEffort>,
106    pub temperature: Option<f32>,
107    pub max_tokens: Option<u32>,
108    pub timeout_secs: u64,
109    pub max_retries: u32,
110    pub max_concurrent: usize,
111}
112
113/// Hand-written so the API key cannot reach a log.
114impl std::fmt::Debug for LlmConfig {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("LlmConfig")
117            .field("enabled", &self.enabled)
118            .field("backend", &self.backend)
119            .field("endpoint", &self.endpoint)
120            .field("model", &self.model)
121            .field(
122                "api_key",
123                &self
124                    .api_key
125                    .as_ref()
126                    .map(|_| "<redacted>")
127                    .unwrap_or("None"),
128            )
129            .field(
130                "api_key_command",
131                &self
132                    .api_key_command
133                    .as_deref()
134                    .map(describe_command)
135                    .unwrap_or_else(|| "None".to_owned()),
136            )
137            .field("protocol", &self.protocol)
138            .field("reasoning_effort", &self.reasoning_effort)
139            .field("temperature", &self.temperature)
140            .field("max_tokens", &self.max_tokens)
141            .field("timeout_secs", &self.timeout_secs)
142            .field("max_retries", &self.max_retries)
143            .field("max_concurrent", &self.max_concurrent)
144            .finish()
145    }
146}
147
148/// The program name plus how many arguments follow it, never the arguments.
149///
150/// The program is the useful non-secret half, the same trade `AuthStore`'s
151/// `Debug` makes by printing its endpoints. The arguments are not that half:
152/// `["vault", "read", "--token=…"]` carries a credential in argv, and so does a
153/// helper invoked as `["sh", "-c", "curl -H 'Authorization: …'"]`. Redacting
154/// them individually would mean deciding which of them looks secret, which is
155/// the judgement call this struct hand-writes `Debug` to avoid making.
156fn describe_command(argv: &[String]) -> String {
157    match argv.split_first() {
158        None => "[]".to_owned(),
159        Some((program, rest)) => format!("[{program}, {} argument(s) redacted]", rest.len()),
160    }
161}
162
163impl Default for LlmConfig {
164    fn default() -> Self {
165        Self {
166            enabled: true,
167            backend: BackendKind::Http,
168            endpoint: None,
169            model: None,
170            api_key: None,
171            api_key_command: None,
172            protocol: None,
173            reasoning_effort: None,
174            temperature: None,
175            max_tokens: None,
176            timeout_secs: 60,
177            max_retries: 3,
178            max_concurrent: 3,
179        }
180    }
181}
182
183/// Backend-sensitive fields explicitly present in one raw `[[llm]]` table.
184///
185/// Captured before TOML deserialization consumes the tree. Keeping booleans
186/// avoids cloning the expanded tree, which may contain an API key.
187#[derive(Clone, Copy, Default)]
188pub(super) struct ExplicitFields {
189    endpoint: bool,
190    api_key: bool,
191    api_key_command: bool,
192    protocol: bool,
193    reasoning_effort: bool,
194    temperature: bool,
195    max_tokens: bool,
196    max_retries: bool,
197}
198
199pub(super) fn explicit_fields(tree: &Value) -> Vec<ExplicitFields> {
200    tree.get("llm")
201        .and_then(Value::as_array)
202        .map(|entries| {
203            entries
204                .iter()
205                .map(|entry| ExplicitFields {
206                    endpoint: entry.get("endpoint").is_some(),
207                    api_key: entry.get("api_key").is_some(),
208                    api_key_command: entry.get("api_key_command").is_some(),
209                    protocol: entry.get("protocol").is_some(),
210                    reasoning_effort: entry.get("reasoning_effort").is_some(),
211                    temperature: entry.get("temperature").is_some(),
212                    max_tokens: entry.get("max_tokens").is_some(),
213                    max_retries: entry.get("max_retries").is_some(),
214                })
215                .collect()
216        })
217        .unwrap_or_default()
218}
219
220pub(super) fn validate(
221    llm: &LlmConfig,
222    fields: ExplicitFields,
223    index: usize,
224) -> Result<(), ConfigError> {
225    if let BackendKind::Unknown(value) = &llm.backend {
226        return Err(ConfigError::UnknownBackend {
227            index,
228            value: value.clone(),
229        });
230    }
231    if let Some(ReasoningEffort::Unknown(value)) = &llm.reasoning_effort {
232        return Err(ConfigError::UnknownReasoningEffort {
233            index,
234            value: value.clone(),
235        });
236    }
237
238    match &llm.backend {
239        BackendKind::Http if fields.reasoning_effort => Err(ConfigError::BackendField {
240            index,
241            backend: "http",
242            field: "reasoning_effort",
243        }),
244        BackendKind::Http => Ok(()),
245        BackendKind::Codex
246            if llm
247                .model
248                .as_deref()
249                .is_none_or(|model| model.trim().is_empty()) =>
250        {
251            Err(ConfigError::BackendMissingField {
252                index,
253                backend: "codex",
254                field: "model",
255            })
256        }
257        BackendKind::Codex => {
258            for (present, field) in [
259                (fields.endpoint, "endpoint"),
260                (fields.api_key, "api_key"),
261                (fields.api_key_command, "api_key_command"),
262                (fields.protocol, "protocol"),
263                (fields.temperature, "temperature"),
264                (fields.max_tokens, "max_tokens"),
265                (fields.max_retries, "max_retries"),
266            ] {
267                if present {
268                    return Err(ConfigError::BackendField {
269                        index,
270                        backend: "codex",
271                        field,
272                    });
273                }
274            }
275            Ok(())
276        }
277        BackendKind::Unknown(_) => unreachable!("handled above"),
278    }
279}