1use std::path::{Path, PathBuf};
15
16use anyhow::{Context, Result, anyhow};
17
18use super::presets::{LlmPreset, PresetBackend};
19use crate::llm::quirks::Quirks;
20
21#[derive(Debug, Clone)]
23enum ChoiceBackend {
24 Http {
25 endpoint: String,
26 key_in_store: bool,
27 quirks: Quirks,
28 },
29 Codex,
30}
31
32#[derive(Debug, Clone)]
38pub struct Choice {
39 pub preset: &'static LlmPreset,
42 pub model: String,
44 backend: ChoiceBackend,
46}
47
48impl Choice {
49 pub fn http(
50 preset: &'static LlmPreset,
51 model: String,
52 endpoint: String,
53 key_in_store: bool,
54 quirks: Quirks,
55 ) -> Self {
56 assert!(
57 matches!(preset.backend, PresetBackend::Http(_)),
58 "HTTP choice requires an HTTP preset"
59 );
60 Self {
61 preset,
62 model,
63 backend: ChoiceBackend::Http {
64 endpoint,
65 key_in_store,
66 quirks,
67 },
68 }
69 }
70
71 pub fn codex(preset: &'static LlmPreset, model: String) -> Self {
72 assert!(
73 matches!(preset.backend, PresetBackend::Codex(_)),
74 "Codex choice requires a Codex preset"
75 );
76 Self {
77 preset,
78 model,
79 backend: ChoiceBackend::Codex,
80 }
81 }
82
83 pub fn is_http(&self) -> bool {
84 matches!(self.backend, ChoiceBackend::Http { .. })
85 }
86
87 #[cfg(test)]
88 pub fn endpoint(&self) -> Option<&str> {
89 match &self.backend {
90 ChoiceBackend::Http { endpoint, .. } => Some(endpoint),
91 ChoiceBackend::Codex => None,
92 }
93 }
94
95 pub fn key_in_store(&self) -> bool {
96 match &self.backend {
97 ChoiceBackend::Http { key_in_store, .. } => *key_in_store,
98 ChoiceBackend::Codex => false,
99 }
100 }
101
102 #[cfg(test)]
103 pub fn quirks(&self) -> Quirks {
104 match &self.backend {
105 ChoiceBackend::Http { quirks, .. } => *quirks,
106 ChoiceBackend::Codex => self.preset.quirks(),
107 }
108 }
109}
110
111pub fn render_chain(choices: &[Choice]) -> String {
121 let mut body = String::new();
122 body.push_str("# drep configuration, written by `drep init`.\n");
123 body.push_str("#\n");
124 body.push_str("# Providers are declared as `[[llm]]`, an ordered array of tables: a\n");
125 body.push_str("# preference order. Each one is tried in turn, and a transport failure -\n");
126 body.push_str("# unreachable, timed out, rate limited, 5xx, or an empty answer - falls\n");
127 body.push_str("# through to the next. A 401 or 403 does not: that is a broken key, and\n");
128 body.push_str("# failing over would hide it. Add a fallback by adding another block:\n");
129 body.push_str("#\n");
130 body.push_str("# [[llm]]\n");
131 body.push_str("# endpoint = \"https://openrouter.ai/api/v1\"\n");
132 body.push_str("# model = \"deepseek/deepseek-v4-pro-0813\"\n");
133 body.push_str("#\n");
134 body.push_str("# Set `enabled = false` on a block to park it without deleting it.\n");
135 body.push_str("#\n");
136 if choices.iter().any(Choice::is_http) {
137 body.push_str(
138 "# API keys are NOT in this file. `drep init` stores them per machine, keyed\n",
139 );
140 body.push_str("# by endpoint, so this file carries only the provider choice and can be\n");
141 body.push_str("# committed. `drep auth list` shows what is stored. To pin a key to a\n");
142 body.push_str(
143 "# variable instead - which is what CI wants - add `api_key = \"${VAR}\"` to\n",
144 );
145 body.push_str("# a block; an explicit value always wins over the stored one.\n");
146 }
147 if choices.iter().any(|choice| !choice.is_http()) {
148 body.push_str("# Codex owns ChatGPT subscription login and token refresh.\n");
149 body.push_str("# Run `codex login`; drep never reads or stores those credentials.\n");
150 }
151
152 for choice in choices {
153 body.push('\n');
154 render_one(&mut body, choice);
155 }
156
157 body
158}
159
160fn render_one(body: &mut String, choice: &Choice) {
162 let preset = choice.preset;
163
164 body.push_str("[[llm]]\n");
165 body.push_str("enabled = true\n");
166 if let (PresetBackend::Codex(codex), ChoiceBackend::Codex) = (&preset.backend, &choice.backend)
167 {
168 body.push_str("backend = \"codex\"\n");
169 body.push_str(&format!("model = \"{}\"\n", escape(&choice.model)));
170 if let Some(effort) = &codex.reasoning_effort {
171 body.push_str(&format!("reasoning_effort = \"{}\"\n", effort.as_str()));
172 }
173 if let Some(timeout) = preset.timeout_secs {
174 body.push_str(&format!("timeout_secs = {timeout}\n"));
175 }
176 body.push_str(&format!("max_concurrent = {}\n", codex.max_concurrent));
177 body.push_str(
178 "# Reviews consume ChatGPT/Codex subscription allowance, not OpenAI API billing.\n",
179 );
180 return;
181 }
182
183 let (http, endpoint, key_in_store, quirks) = match (&preset.backend, &choice.backend) {
184 (
185 PresetBackend::Http(http),
186 ChoiceBackend::Http {
187 endpoint,
188 key_in_store,
189 quirks,
190 },
191 ) => (http, endpoint.as_str(), *key_in_store, quirks),
192 _ => panic!("choice backend does not match preset `{}`", preset.key),
193 };
194 body.push_str(&format!("endpoint = \"{}\"\n", escape(endpoint)));
195 body.push_str(&format!("model = \"{}\"\n", escape(&choice.model)));
196
197 if !key_in_store && let Some(env) = http.api_key_env {
201 body.push_str(&format!("api_key = \"${{{env}}}\"\n"));
202 }
203
204 if let Some(protocol) = http.protocol {
207 body.push_str("# This endpoint speaks Anthropic's messages API, not chat completions.\n");
208 body.push_str(&format!("protocol = \"{}\"\n", escape(protocol)));
209 }
210
211 if let Some(max_tokens) = quirks.max_tokens {
218 body.push_str("# Required by this endpoint: it refuses a request that omits the field.\n");
219 if quirks.max_tokens_from_registry {
220 body.push_str(
221 "# This is the model's own published output limit, not a cap drep chose.\n",
222 );
223 } else {
224 body.push_str(
225 "# This model's own limit is not known here, so it is the provider's fallback:\n\
226 # set well above any review-sized response.\n",
227 );
228 }
229 body.push_str(&format!("max_tokens = {max_tokens}\n"));
230 }
231
232 match quirks.temperature {
236 Some(temperature) => {
237 body.push_str(&format!("temperature = {temperature:?}\n"));
241 }
242 None => {
243 body.push_str("# `temperature` is deliberately absent: this model rejects the\n");
244 body.push_str("# parameter, and the resulting 400 neither fails over nor retries.\n");
245 }
246 }
247
248 if let Some(timeout) = preset.timeout_secs {
249 body.push_str(
250 "# A reasoning model can spend minutes on one file; the wall clock has to match.\n",
251 );
252 body.push_str(&format!("timeout_secs = {timeout}\n"));
253 }
254
255 if quirks.max_tokens.is_none() {
256 body.push_str(
257 "# max_tokens is deliberately unset: with no completion cap, a reasoning model\n",
258 );
259 body.push_str("# is never truncated mid-thought. Set it only to cap spend.\n");
260 }
261}
262
263pub fn already_exists(path: &Path) -> anyhow::Error {
270 anyhow!(
271 "{} already exists. Re-run with --force to replace it.",
272 path.display()
273 )
274}
275
276fn escape(s: &str) -> String {
290 let mut out = String::with_capacity(s.len());
291 for ch in s.chars() {
292 match ch {
293 '\\' => out.push_str("\\\\"),
294 '"' => out.push_str("\\\""),
295 '\n' => out.push_str("\\n"),
296 '\r' => out.push_str("\\r"),
297 '\t' => out.push_str("\\t"),
298 '\u{8}' => out.push_str("\\b"),
299 '\u{c}' => out.push_str("\\f"),
300 c if c.is_control() => out.push_str(&format!("\\u{:04X}", c as u32)),
302 other => out.push(other),
303 }
304 }
305 out
306}
307
308pub fn write(root: &Path, body: &str, force: bool) -> Result<PathBuf> {
314 use std::io::Write;
315
316 let path = root.join(crate::config::default_config_path());
317 let mut temporary = tempfile::NamedTempFile::new_in(root)
318 .with_context(|| format!("could not write {}", path.display()))?;
319 temporary
320 .write_all(body.as_bytes())
321 .with_context(|| format!("could not write {}", path.display()))?;
322 temporary
323 .as_file()
324 .sync_all()
325 .with_context(|| format!("could not write {}", path.display()))?;
326
327 let published = if force {
331 temporary.persist(&path)
332 } else {
333 temporary.persist_noclobber(&path)
334 };
335 match published {
336 Ok(_) => Ok(path),
337 Err(err) => match (force, err.error.kind()) {
338 (false, std::io::ErrorKind::AlreadyExists) => Err(already_exists(&path)),
339 _ => Err(anyhow::Error::new(err.error)
340 .context(format!("could not write {}", path.display()))),
341 },
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn a_write_that_fails_for_another_reason_is_not_reported_as_already_existing() {
351 let dir = tempfile::tempdir().expect("tempdir");
356 let missing = dir.path().join("no-such-directory");
357
358 let err = write(&missing, "model = \"x\"\n", false)
359 .expect_err("there is no directory to write into");
360
361 assert!(
362 !err.to_string().contains("already exists"),
363 "nothing exists here; got {err}"
364 );
365 assert!(err.to_string().contains("could not write"), "got {err}");
366 }
367
368 #[cfg(unix)]
369 #[test]
370 fn a_dangling_symlink_named_drep_toml_is_refused_rather_than_followed() {
371 let dir = tempfile::tempdir().expect("tempdir");
375 let elsewhere = dir.path().join("elsewhere.toml");
376 std::os::unix::fs::symlink(&elsewhere, dir.path().join("drep.toml")).expect("symlink");
377
378 let err = write(dir.path(), "model = \"x\"\n", false)
379 .expect_err("a dangling symlink is still something being there");
380
381 assert!(err.to_string().contains("drep.toml"), "got {err}");
382 assert!(
383 !elsewhere.exists(),
384 "and nothing was written through it to {}",
385 elsewhere.display()
386 );
387 }
388
389 #[test]
390 fn escape_handles_backslash_and_quote() {
391 assert_eq!(escape("plain"), "plain");
392 assert_eq!(escape(r#"a"b"#), r#"a\"b"#);
393 assert_eq!(escape(r"a\b"), r"a\\b");
394 assert_eq!(escape(r#"a"b\c"#), r#"a\"b\\c"#);
395 }
396}