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 .field("endpoint", &self.endpoint)
158 .field("model", &self.model)
159 .field(
160 "api_key",
161 &self
162 .api_key
163 .as_ref()
164 .map(|_| "<redacted>")
165 .unwrap_or("None"),
166 )
167 .field(
168 "api_key_command",
169 &self
170 .api_key_command
171 .as_deref()
172 .map(describe_command)
173 .unwrap_or_else(|| "None".to_owned()),
174 )
175 // Names only, spelled exactly as `LlmClient`'s `Debug` spells it:
176 // a header value is as likely to be a credential as `api_key` is.
177 .field(
178 "headers",
179 &self.headers.keys().map(String::as_str).collect::<Vec<_>>(),
180 )
181 .field("protocol", &self.protocol)
182 .field("reasoning_effort", &self.reasoning_effort)
183 .field("temperature", &self.temperature)
184 .field("max_tokens", &self.max_tokens)
185 .field("timeout_secs", &self.timeout_secs)
186 .field("max_retries", &self.max_retries)
187 .field("max_concurrent", &self.max_concurrent)
188 .finish()
189 }
190}
191
192/// The program name plus how many arguments follow it, never the arguments.
193///
194/// The program is the useful non-secret half, the same trade `AuthStore`'s
195/// `Debug` makes by printing its endpoints. The arguments are not that half:
196/// `["vault", "read", "--token=…"]` carries a credential in argv, and so does a
197/// helper invoked as `["sh", "-c", "curl -H 'Authorization: …'"]`. Redacting
198/// them individually would mean deciding which of them looks secret, which is
199/// the judgement call this struct hand-writes `Debug` to avoid making.
200fn describe_command(argv: &[String]) -> String {
201 match argv.split_first() {
202 None => "[]".to_owned(),
203 Some((program, rest)) => format!("[{program}, {} argument(s) redacted]", rest.len()),
204 }
205}
206
207impl Default for LlmConfig {
208 fn default() -> Self {
209 Self {
210 enabled: true,
211 backend: BackendKind::Http,
212 endpoint: None,
213 model: None,
214 api_key: None,
215 api_key_command: None,
216 headers: BTreeMap::new(),
217 protocol: None,
218 reasoning_effort: None,
219 temperature: None,
220 max_tokens: None,
221 timeout_secs: 60,
222 max_retries: 3,
223 max_concurrent: 3,
224 }
225 }
226}
227
228/// Backend-sensitive fields explicitly present in one raw `[[llm]]` table.
229///
230/// Captured before TOML deserialization consumes the tree. Keeping booleans
231/// avoids cloning the expanded tree, which may contain an API key.
232#[derive(Clone, Copy, Default)]
233pub(super) struct ExplicitFields {
234 endpoint: bool,
235 api_key: bool,
236 api_key_command: bool,
237 headers: bool,
238 protocol: bool,
239 reasoning_effort: bool,
240 temperature: bool,
241 max_tokens: bool,
242 max_retries: bool,
243}
244
245pub(super) fn explicit_fields(tree: &Value) -> Vec<ExplicitFields> {
246 tree.get("llm")
247 .and_then(Value::as_array)
248 .map(|entries| {
249 entries
250 .iter()
251 .map(|entry| ExplicitFields {
252 endpoint: entry.get("endpoint").is_some(),
253 api_key: entry.get("api_key").is_some(),
254 api_key_command: entry.get("api_key_command").is_some(),
255 headers: entry.get("headers").is_some(),
256 protocol: entry.get("protocol").is_some(),
257 reasoning_effort: entry.get("reasoning_effort").is_some(),
258 temperature: entry.get("temperature").is_some(),
259 max_tokens: entry.get("max_tokens").is_some(),
260 max_retries: entry.get("max_retries").is_some(),
261 })
262 .collect()
263 })
264 .unwrap_or_default()
265}
266
267pub(super) fn validate(
268 llm: &LlmConfig,
269 fields: ExplicitFields,
270 index: usize,
271) -> Result<(), ConfigError> {
272 if let BackendKind::Unknown(value) = &llm.backend {
273 return Err(ConfigError::UnknownBackend {
274 index,
275 value: value.clone(),
276 });
277 }
278 if let Some(ReasoningEffort::Unknown(value)) = &llm.reasoning_effort {
279 return Err(ConfigError::UnknownReasoningEffort {
280 index,
281 value: value.clone(),
282 });
283 }
284
285 match &llm.backend {
286 BackendKind::Http if fields.reasoning_effort => Err(ConfigError::BackendField {
287 index,
288 backend: "http",
289 field: "reasoning_effort",
290 }),
291 BackendKind::Http => Ok(()),
292 BackendKind::Codex
293 if llm
294 .model
295 .as_deref()
296 .is_none_or(|model| model.trim().is_empty()) =>
297 {
298 Err(ConfigError::BackendMissingField {
299 index,
300 backend: "codex",
301 field: "model",
302 })
303 }
304 BackendKind::Codex => {
305 for (present, field) in [
306 (fields.endpoint, "endpoint"),
307 (fields.api_key, "api_key"),
308 (fields.api_key_command, "api_key_command"),
309 (fields.headers, "headers"),
310 (fields.protocol, "protocol"),
311 (fields.temperature, "temperature"),
312 (fields.max_tokens, "max_tokens"),
313 (fields.max_retries, "max_retries"),
314 ] {
315 if present {
316 return Err(ConfigError::BackendField {
317 index,
318 backend: "codex",
319 field,
320 });
321 }
322 }
323 Ok(())
324 }
325 BackendKind::Unknown(_) => unreachable!("handled above"),
326 }
327}
328
329/// Fails to compile when a field is added to [`LlmConfig`] without deciding what
330/// the three hand-maintained lists beside it should say.
331///
332/// `ExplicitFields`, `explicit_fields` and the Codex rejection list in
333/// [`validate`] are parallel to this struct and kept in step by hand. Adding
334/// `headers` took six coordinated edits and nothing would have failed had the
335/// last two been missed - a Codex entry would simply have started accepting an
336/// HTTP-only field, defeating the documented guarantee that a subscription
337/// selection cannot silently become API billing. `deny_unknown_fields` cannot
338/// catch that: the field is known, it is the lists that forgot it.
339///
340/// The same guard `config::site` uses for its policy fields, for the reason
341/// stated there: a list kept in step with a type by hand is a list that drifts
342/// silently.
343#[cfg(test)]
344fn _every_provider_field_is_classified(config: &LlmConfig) {
345 let LlmConfig {
346 // Not backend-specific: every entry has these whatever it runs.
347 enabled: _,
348 backend: _,
349 model: _,
350 max_concurrent: _,
351 timeout_secs: _,
352 // HTTP-only: each of these has a row in the Codex rejection list in
353 // `validate`, and a field in `ExplicitFields` so the rejection can tell
354 // "written" from "defaulted".
355 endpoint: _,
356 api_key: _,
357 api_key_command: _,
358 headers: _,
359 protocol: _,
360 temperature: _,
361 max_tokens: _,
362 max_retries: _,
363 // Codex-only: rejected on an HTTP entry by the arm above.
364 reasoning_effort: _,
365 } = config;
366}