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 std::collections::BTreeMap;
7
8use serde::{Deserialize, Deserializer};
9use toml::Value;
10
11use super::ConfigError;
12
13/// Which implementation serves one provider entry.
14#[derive(Clone, Debug, Default, PartialEq, Eq)]
15pub enum BackendKind {
16    /// Direct HTTP through `open-agent-sdk`.
17    #[default]
18    Http,
19    /// The installed Codex CLI using its saved ChatGPT authentication.
20    Codex,
21    /// Retained only so a disabled entry remains inert; enabled entries reject it.
22    Unknown(String),
23}
24
25impl BackendKind {
26    /// Stable configuration and cache identity.
27    pub fn as_str(&self) -> &str {
28        match self {
29            Self::Http => "http",
30            Self::Codex => "codex",
31            Self::Unknown(value) => value,
32        }
33    }
34}
35
36impl<'de> Deserialize<'de> for BackendKind {
37    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
38    where
39        D: Deserializer<'de>,
40    {
41        let value = String::deserialize(deserializer)?;
42        Ok(match value.as_str() {
43            "http" => Self::Http,
44            "codex" => Self::Codex,
45            _ => Self::Unknown(value),
46        })
47    }
48}
49
50/// Reasoning effort accepted by the Codex CLI configuration contract.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum ReasoningEffort {
53    Minimal,
54    Low,
55    Medium,
56    High,
57    Xhigh,
58    /// Retained only so a disabled entry remains inert; enabled entries reject it.
59    Unknown(String),
60}
61
62impl ReasoningEffort {
63    pub fn as_str(&self) -> &str {
64        match self {
65            Self::Minimal => "minimal",
66            Self::Low => "low",
67            Self::Medium => "medium",
68            Self::High => "high",
69            Self::Xhigh => "xhigh",
70            Self::Unknown(value) => value,
71        }
72    }
73}
74
75impl<'de> Deserialize<'de> for ReasoningEffort {
76    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
77    where
78        D: Deserializer<'de>,
79    {
80        let value = String::deserialize(deserializer)?;
81        Ok(match value.as_str() {
82            "minimal" => Self::Minimal,
83            "low" => Self::Low,
84            "medium" => Self::Medium,
85            "high" => Self::High,
86            "xhigh" => Self::Xhigh,
87            _ => Self::Unknown(value),
88        })
89    }
90}
91
92/// One provider in the failover chain.
93///
94/// `deny_unknown_fields` because the alternative is what a misspelled key was
95/// doing until this attribute arrived: serde dropped it without a word, and a
96/// user who wrote `[llm.headers]` before drep could send one got a config that
97/// read as configured and sent nothing. That is the same silent-drop failure
98/// `ConfigError::SiteOnlyField` exists to refuse one file over, and there is no
99/// reason for `drep.toml` to be laxer than the policy file about it.
100///
101/// It is the one pass that does not honour "a disabled entry is inert", because
102/// serde rejects at deserialization and there is no entry yet to skip. So a
103/// parked provider carrying a field from a newer drep refuses to load the file
104/// rather than being ignored. That is the wanted trade - a typo in a parked
105/// entry is still a typo, and the entry is one line from being re-enabled - but
106/// it is a deviation from a rule `${VAR}` expansion, field validation and
107/// credential resolution all keep.
108#[derive(Deserialize)]
109#[serde(default, deny_unknown_fields)]
110pub struct LlmConfig {
111    pub enabled: bool,
112    pub backend: BackendKind,
113    pub endpoint: Option<String>,
114    pub model: Option<String>,
115    pub api_key: Option<String>,
116    /// An argv - never a shell line - whose trimmed stdout is the credential.
117    ///
118    /// Declared after `api_key` because the field order is the resolution order:
119    /// an explicit key wins, then this, then the per-machine store.
120    pub api_key_command: Option<Vec<String>>,
121    /// Extra HTTP headers sent with every request to this provider.
122    ///
123    /// For the gateway that identifies its clients by `User-Agent`, bills
124    /// against a header, or authenticates outside its protocol's default
125    /// scheme. A name that collides with one the protocol sets replaces it, so
126    /// this can carry an `Authorization` the SDK's own scheme would not produce.
127    ///
128    /// **A value here can be a credential.** A project or tenant token is the
129    /// ordinary case, which is the whole reason `Debug` here, `LlmClient`'s
130    /// `Debug`, `doctor`'s listing and `ConfigError::UnusableHeaderValue` all
131    /// print the name and never the value. This is the one place that argument
132    /// is made; the others cite it.
133    ///
134    /// A `BTreeMap` rather than a list of pairs, so the rendered config and the
135    /// `doctor` listing come out in a stable order. That alone does not make the
136    /// set unambiguous: two spellings of one name are two map keys and one HTTP
137    /// header, so `ConfigError::DuplicateHeaderName` refuses that pair at load
138    /// rather than letting byte order decide which of them is sent.
139    /// `config::effective_headers` overlays what survives on drep's own defaults
140    /// to get what is actually sent.
141    pub headers: BTreeMap<String, String>,
142    pub protocol: Option<String>,
143    pub reasoning_effort: Option<ReasoningEffort>,
144    pub temperature: Option<f32>,
145    pub max_tokens: Option<u32>,
146    pub timeout_secs: u64,
147    pub max_retries: u32,
148    pub max_concurrent: usize,
149}
150
151/// Hand-written so the API key cannot reach a log.
152impl std::fmt::Debug for LlmConfig {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_struct("LlmConfig")
155            .field("enabled", &self.enabled)
156            .field("backend", &self.backend)
157            // `user:password@` in an endpoint is a Basic-auth credential.
158            .field(
159                "endpoint",
160                &self.endpoint.as_deref().map(crate::auth::display_endpoint),
161            )
162            .field("model", &self.model)
163            .field(
164                "api_key",
165                &self
166                    .api_key
167                    .as_ref()
168                    .map(|_| "<redacted>")
169                    .unwrap_or("None"),
170            )
171            .field(
172                "api_key_command",
173                &self
174                    .api_key_command
175                    .as_deref()
176                    .map(describe_command)
177                    .unwrap_or_else(|| "None".to_owned()),
178            )
179            // Names only, spelled exactly as `LlmClient`'s `Debug` spells it:
180            // a header value is as likely to be a credential as `api_key` is.
181            .field(
182                "headers",
183                &self.headers.keys().map(String::as_str).collect::<Vec<_>>(),
184            )
185            .field("protocol", &self.protocol)
186            .field("reasoning_effort", &self.reasoning_effort)
187            .field("temperature", &self.temperature)
188            .field("max_tokens", &self.max_tokens)
189            .field("timeout_secs", &self.timeout_secs)
190            .field("max_retries", &self.max_retries)
191            .field("max_concurrent", &self.max_concurrent)
192            .finish()
193    }
194}
195
196/// The program name plus how many arguments follow it, never the arguments.
197///
198/// The program is the useful non-secret half, the same trade `AuthStore`'s
199/// `Debug` makes by printing its endpoints. The arguments are not that half:
200/// `["vault", "read", "--token=…"]` carries a credential in argv, and so does a
201/// helper invoked as `["sh", "-c", "curl -H 'Authorization: …'"]`. Redacting
202/// them individually would mean deciding which of them looks secret, which is
203/// the judgement call this struct hand-writes `Debug` to avoid making.
204fn describe_command(argv: &[String]) -> String {
205    match argv.split_first() {
206        None => "[]".to_owned(),
207        Some((program, rest)) => format!("[{program}, {} argument(s) redacted]", rest.len()),
208    }
209}
210
211impl Default for LlmConfig {
212    fn default() -> Self {
213        Self {
214            enabled: true,
215            backend: BackendKind::Http,
216            endpoint: None,
217            model: None,
218            api_key: None,
219            api_key_command: None,
220            headers: BTreeMap::new(),
221            protocol: None,
222            reasoning_effort: None,
223            temperature: None,
224            max_tokens: None,
225            timeout_secs: 60,
226            max_retries: 3,
227            max_concurrent: 3,
228        }
229    }
230}
231
232/// Backend-sensitive fields explicitly present in one raw `[[llm]]` table.
233///
234/// Captured before TOML deserialization consumes the tree. Keeping booleans
235/// avoids cloning the expanded tree, which may contain an API key.
236#[derive(Clone, Copy, Default)]
237pub(super) struct ExplicitFields {
238    endpoint: bool,
239    api_key: bool,
240    api_key_command: bool,
241    headers: bool,
242    protocol: bool,
243    reasoning_effort: bool,
244    temperature: bool,
245    max_tokens: bool,
246    max_retries: bool,
247}
248
249pub(super) fn explicit_fields(tree: &Value) -> Vec<ExplicitFields> {
250    tree.get("llm")
251        .and_then(Value::as_array)
252        .map(|entries| {
253            entries
254                .iter()
255                .map(|entry| ExplicitFields {
256                    endpoint: entry.get("endpoint").is_some(),
257                    api_key: entry.get("api_key").is_some(),
258                    api_key_command: entry.get("api_key_command").is_some(),
259                    headers: entry.get("headers").is_some(),
260                    protocol: entry.get("protocol").is_some(),
261                    reasoning_effort: entry.get("reasoning_effort").is_some(),
262                    temperature: entry.get("temperature").is_some(),
263                    max_tokens: entry.get("max_tokens").is_some(),
264                    max_retries: entry.get("max_retries").is_some(),
265                })
266                .collect()
267        })
268        .unwrap_or_default()
269}
270
271pub(super) fn validate(
272    llm: &LlmConfig,
273    fields: ExplicitFields,
274    index: usize,
275) -> Result<(), ConfigError> {
276    if let BackendKind::Unknown(value) = &llm.backend {
277        return Err(ConfigError::UnknownBackend {
278            index,
279            value: value.clone(),
280        });
281    }
282    if let Some(ReasoningEffort::Unknown(value)) = &llm.reasoning_effort {
283        return Err(ConfigError::UnknownReasoningEffort {
284            index,
285            value: value.clone(),
286        });
287    }
288
289    match &llm.backend {
290        BackendKind::Http if fields.reasoning_effort => Err(ConfigError::BackendField {
291            index,
292            backend: "http",
293            field: "reasoning_effort",
294        }),
295        BackendKind::Http => Ok(()),
296        BackendKind::Codex
297            if llm
298                .model
299                .as_deref()
300                .is_none_or(|model| model.trim().is_empty()) =>
301        {
302            Err(ConfigError::BackendMissingField {
303                index,
304                backend: "codex",
305                field: "model",
306            })
307        }
308        BackendKind::Codex => {
309            for (present, field) in [
310                (fields.endpoint, "endpoint"),
311                (fields.api_key, "api_key"),
312                (fields.api_key_command, "api_key_command"),
313                (fields.headers, "headers"),
314                (fields.protocol, "protocol"),
315                (fields.temperature, "temperature"),
316                (fields.max_tokens, "max_tokens"),
317                (fields.max_retries, "max_retries"),
318            ] {
319                if present {
320                    return Err(ConfigError::BackendField {
321                        index,
322                        backend: "codex",
323                        field,
324                    });
325                }
326            }
327            Ok(())
328        }
329        BackendKind::Unknown(_) => unreachable!("handled above"),
330    }
331}
332
333/// Fails to compile when a field is added to [`LlmConfig`] without deciding what
334/// the three hand-maintained lists beside it should say.
335///
336/// `ExplicitFields`, `explicit_fields` and the Codex rejection list in
337/// [`validate`] are parallel to this struct and kept in step by hand. Adding
338/// `headers` took six coordinated edits and nothing would have failed had the
339/// last two been missed - a Codex entry would simply have started accepting an
340/// HTTP-only field, defeating the documented guarantee that a subscription
341/// selection cannot silently become API billing. `deny_unknown_fields` cannot
342/// catch that: the field is known, it is the lists that forgot it.
343///
344/// The same guard `config::site` uses for its policy fields, for the reason
345/// stated there: a list kept in step with a type by hand is a list that drifts
346/// silently.
347#[cfg(test)]
348fn _every_provider_field_is_classified(config: &LlmConfig) {
349    let LlmConfig {
350        // Not backend-specific: every entry has these whatever it runs.
351        enabled: _,
352        backend: _,
353        model: _,
354        max_concurrent: _,
355        timeout_secs: _,
356        // HTTP-only: each of these has a row in the Codex rejection list in
357        // `validate`, and a field in `ExplicitFields` so the rejection can tell
358        // "written" from "defaulted".
359        endpoint: _,
360        api_key: _,
361        api_key_command: _,
362        headers: _,
363        protocol: _,
364        temperature: _,
365        max_tokens: _,
366        max_retries: _,
367        // Codex-only: rejected on an HTTP entry by the arm above.
368        reasoning_effort: _,
369    } = config;
370}