Skip to main content

drep/cli/init/
presets.rs

1//! Named LLM providers for `drep init`.
2//!
3//! A preset stops a user having to know the OpenAI-compatible protocol - they
4//! pick "OpenRouter", not "openai-compatible with base URL
5//! `https://openrouter.ai/api/v1`". Every cloud option here speaks that
6//! protocol; the difference between them is an endpoint, a model, and which
7//! environment variable holds the key.
8//!
9//! Model defaults are the one thing here that goes stale, which is why the
10//! wizard no longer relies on them: it asks the endpoint what it serves and
11//! uses the default only to *preselect* an entry (see [`crate::llm::models`]).
12//! A default the endpoint no longer offers is called out at the prompt rather
13//! than silently replaced, so a stale one here is visible instead of inherited.
14//! They remain the answer for `--provider` runs, which have no prompt.
15//!
16//! **`max_tokens` is deliberately absent.** The Python presets set it to
17//! 100,000 for reasoning models. In 2.0 no cap is sent unless the user sets
18//! one - see the `max_tokens` note in [`crate::config`]. A preset that set one
19//! would reintroduce exactly the coupling that was removed. The one exception
20//! is an endpoint that *refuses a request without the field*, where the preset
21//! records that requirement and a value to fall back on.
22//!
23//! **`temperature` and `max_tokens` are properties of a model, not a
24//! provider**, so what a preset holds for them is a starting point rather than
25//! an answer. [`LlmPreset::quirks`] turns the pair into a
26//! [`Quirks`](crate::llm::quirks::Quirks), which
27//! [`quirks::resolve`](crate::llm::quirks::resolve) then narrows against the
28//! model the user actually chose. The preset's values are what a run falls back
29//! to whenever the registry cannot name that model - which is every offline
30//! run, every `--provider` run, and every model released since the cache was
31//! written.
32
33use crate::config::ReasoningEffort;
34
35/// HTTP-specific preset fields.
36#[derive(Debug)]
37pub struct HttpPreset {
38    pub endpoint: Option<&'static str>,
39    pub api_key_env: Option<&'static str>,
40    pub protocol: Option<&'static str>,
41    pub key_url: Option<&'static str>,
42    pub max_tokens: Option<u32>,
43    pub temperature: Option<f32>,
44}
45
46/// Codex-specific preset fields.
47#[derive(Debug)]
48pub struct CodexPreset {
49    pub reasoning_effort: Option<ReasoningEffort>,
50    pub max_concurrent: usize,
51}
52
53/// Backend-specific fields that cannot be combined across execution paths.
54#[derive(Debug)]
55pub enum PresetBackend {
56    Http(HttpPreset),
57    Codex(CodexPreset),
58}
59
60/// One named way to reach a model.
61#[derive(Debug)]
62pub struct LlmPreset {
63    /// The name clap and `init` accept on the command line.
64    pub key: &'static str,
65    /// What the wizard shows.
66    pub display_name: &'static str,
67    /// One line on when to pick it.
68    pub description: &'static str,
69    /// Direct HTTP or the separately installed Codex CLI, with only the fields
70    /// that backend can use.
71    pub backend: PresetBackend,
72    /// Starting point for the model prompt, or `None` if the user must supply.
73    pub default_model: Option<&'static str>,
74    /// Request timeout. `None` inherits `LlmConfig`'s default of 60s.
75    pub timeout_secs: Option<u64>,
76}
77
78/// Every preset, in the order the wizard should offer them.
79///
80/// Order matters - it is what `drep init`'s `--help` and the `--provider`
81/// completions show, and it is what the tests assert.
82pub static PRESETS: &[&LlmPreset] = &[
83    &LOCAL,
84    &OPENROUTER,
85    &ZAI,
86    &MINIMAX,
87    &KIMI,
88    &OPENAI,
89    &CODEX,
90    &CUSTOM,
91];
92
93/// LM Studio, Ollama or llama.cpp on this machine. No key, no cost.
94pub static LOCAL: LlmPreset = LlmPreset {
95    key: "local",
96    display_name: "Local model",
97    description: "LM Studio, Ollama or llama.cpp on this machine. No key, no cost.",
98    backend: PresetBackend::Http(HttpPreset {
99        endpoint: Some("http://localhost:1234/v1"),
100        key_url: None,
101        api_key_env: None,
102        protocol: None,
103        max_tokens: None,
104        temperature: Some(0.2),
105    }),
106    default_model: Some("qwen3-30b-a3b"),
107    timeout_secs: None,
108};
109
110/// One key for many providers. Good default for cloud analysis.
111pub static OPENROUTER: LlmPreset = LlmPreset {
112    key: "openrouter",
113    display_name: "OpenRouter",
114    description: "One key for many providers. Good default for cloud analysis.",
115    backend: PresetBackend::Http(HttpPreset {
116        endpoint: Some("https://openrouter.ai/api/v1"),
117        key_url: Some("https://openrouter.ai/keys"),
118        api_key_env: Some("OPENROUTER_API_KEY"),
119        protocol: None,
120        max_tokens: None,
121        temperature: Some(0.2),
122    }),
123    default_model: Some("deepseek/deepseek-v4-pro-0813"),
124    timeout_secs: Some(1800),
125};
126
127/// Directly against the OpenAI API.
128pub static OPENAI: LlmPreset = LlmPreset {
129    key: "openai",
130    display_name: "OpenAI API",
131    description: "Directly against the OpenAI API.",
132    backend: PresetBackend::Http(HttpPreset {
133        endpoint: Some("https://api.openai.com/v1"),
134        key_url: Some("https://platform.openai.com/api-keys"),
135        api_key_env: Some("OPENAI_API_KEY"),
136        protocol: None,
137        max_tokens: None,
138        // gpt-5.6-sol rejects `temperature` outright, so none is sent.
139        temperature: None,
140    }),
141    default_model: Some("gpt-5.6-sol"),
142    timeout_secs: Some(1800),
143};
144
145/// The installed Codex CLI using the user's ChatGPT/Codex subscription.
146pub static CODEX: LlmPreset = LlmPreset {
147    key: "codex",
148    display_name: "ChatGPT / Codex subscription",
149    description: "Codex CLI with ChatGPT-managed authentication; no API billing.",
150    backend: PresetBackend::Codex(CodexPreset {
151        reasoning_effort: Some(ReasoningEffort::High),
152        max_concurrent: 1,
153    }),
154    default_model: Some("gpt-5.6-sol"),
155    timeout_secs: Some(1800),
156};
157
158/// z.ai's GLM Coding Plan. OpenAI-compatible, and accepts a temperature.
159pub static ZAI: LlmPreset = LlmPreset {
160    key: "zai",
161    display_name: "z.ai GLM Coding Plan",
162    description: "GLM models on a coding-plan subscription. OpenAI-compatible.",
163    backend: PresetBackend::Http(HttpPreset {
164        endpoint: Some("https://api.z.ai/api/coding/paas/v4"),
165        key_url: Some("https://z.ai/manage-apikey/apikey-list"),
166        api_key_env: Some("ZAI_API_KEY"),
167        protocol: None,
168        max_tokens: None,
169        temperature: Some(0.2),
170    }),
171    default_model: Some("glm-5.3"),
172    timeout_secs: Some(1800),
173};
174
175/// MiniMax's Token Plan, over its Anthropic-compatible endpoint.
176///
177/// MiniMax publishes both `/v1` (OpenAI-compatible) and `/anthropic/v1`. The
178/// Anthropic one is the preset because it is the only one that separates the
179/// reasoning channel: over `/v1` the M-series returns its whole trace inline in
180/// `message.content` wrapped in `<think>` tags, which drep has to strip back
181/// out. Both work; one of them needs no repair.
182pub static MINIMAX: LlmPreset = LlmPreset {
183    key: "minimax",
184    display_name: "MiniMax Token Plan",
185    description: "MiniMax M-series on a token-plan subscription. Anthropic protocol.",
186    backend: PresetBackend::Http(HttpPreset {
187        endpoint: Some("https://api.minimax.io/anthropic/v1"),
188        key_url: Some("https://platform.minimax.io/user-center/payment/token-plan"),
189        api_key_env: Some("MINIMAX_API_KEY"),
190        protocol: Some("anthropic"),
191        max_tokens: None,
192        temperature: Some(0.2),
193    }),
194    default_model: Some("MiniMax-M3"),
195    timeout_secs: Some(1800),
196};
197
198/// Moonshot's Kimi for Coding plan. Anthropic protocol, and no temperature.
199pub static KIMI: LlmPreset = LlmPreset {
200    key: "kimi",
201    display_name: "Kimi for Coding",
202    description: "Moonshot's k3 on a coding-plan subscription. Anthropic protocol.",
203    backend: PresetBackend::Http(HttpPreset {
204        endpoint: Some("https://api.kimi.com/coding/v1"),
205        key_url: Some("https://www.kimi.com/code"),
206        api_key_env: Some("KIMI_API_KEY"),
207        protocol: Some("anthropic"),
208        // Required by this endpoint, not a ceiling: without it the request is refused
209        // with a bare `invalid_request_error` 400 that names no field. This value is
210        // only the fallback for a model the quirks registry cannot name; for one it
211        // can, the model's own published output limit is written instead. Verified
212        // accepted by the live endpoint, which is what a fallback has to be.
213        max_tokens: Some(200_000),
214        // k3 answers `only temperature 1 is allowed for this model` with a 400, which
215        // neither fails over nor retries. Sending none is the only value that works.
216        temperature: None,
217    }),
218    default_model: Some("k3"),
219    timeout_secs: Some(1800),
220};
221
222/// Any other OpenAI-compatible endpoint.
223pub static CUSTOM: LlmPreset = LlmPreset {
224    key: "custom",
225    display_name: "Custom endpoint",
226    description: "Any other OpenAI-compatible endpoint.",
227    backend: PresetBackend::Http(HttpPreset {
228        endpoint: None,
229        key_url: None,
230        api_key_env: Some("LLM_API_KEY"),
231        protocol: None,
232        max_tokens: None,
233        temperature: Some(0.2),
234    }),
235    default_model: None,
236    timeout_secs: None,
237};
238
239impl LlmPreset {
240    #[cfg(test)]
241    pub fn backend_kind(&self) -> crate::config::BackendKind {
242        match self.backend {
243            PresetBackend::Http(_) => crate::config::BackendKind::Http,
244            PresetBackend::Codex(_) => crate::config::BackendKind::Codex,
245        }
246    }
247
248    pub fn http(&self) -> Option<&HttpPreset> {
249        match &self.backend {
250            PresetBackend::Http(http) => Some(http),
251            PresetBackend::Codex(_) => None,
252        }
253    }
254
255    #[cfg(test)]
256    pub fn codex(&self) -> Option<&CodexPreset> {
257        match &self.backend {
258            PresetBackend::Codex(codex) => Some(codex),
259            PresetBackend::Http(_) => None,
260        }
261    }
262
263    pub fn endpoint(&self) -> Option<&'static str> {
264        self.http().and_then(|http| http.endpoint)
265    }
266
267    pub fn api_key_env(&self) -> Option<&'static str> {
268        self.http().and_then(|http| http.api_key_env)
269    }
270
271    pub fn key_url(&self) -> Option<&'static str> {
272        self.http().and_then(|http| http.key_url)
273    }
274
275    #[cfg(test)]
276    pub fn protocol_name(&self) -> Option<&'static str> {
277        self.http().and_then(|http| http.protocol)
278    }
279
280    #[cfg(test)]
281    pub fn max_tokens(&self) -> Option<u32> {
282        self.http().and_then(|http| http.max_tokens)
283    }
284
285    #[cfg(test)]
286    pub fn temperature(&self) -> Option<f32> {
287        self.http().and_then(|http| http.temperature)
288    }
289
290    /// This preset's starting point for the per-model parameters.
291    ///
292    /// What `drep init` wrote before the quirks registry existed, and what
293    /// every path that cannot consult it still writes: the `--provider` flag
294    /// path, an offline run, and any model the registry does not name.
295    pub fn quirks(&self) -> crate::llm::quirks::Quirks {
296        match self.http() {
297            Some(http) => crate::llm::quirks::Quirks {
298                temperature: http.temperature,
299                max_tokens: http.max_tokens,
300                max_tokens_from_registry: false,
301            },
302            None => crate::llm::quirks::Quirks {
303                temperature: None,
304                max_tokens: None,
305                max_tokens_from_registry: false,
306            },
307        }
308    }
309
310    /// The wire protocol this preset's endpoint speaks.
311    ///
312    /// The table stores a string because that is what `drep.toml` carries and
313    /// what `config_file::render_one` writes. Parsing it here rather than at
314    /// each use means a typo in the table is a panic in the preset tests rather
315    /// than an `unwrap_or_default()` that silently builds an OpenAI client for
316    /// an Anthropic endpoint.
317    pub fn protocol(&self) -> open_agent::ApiProtocol {
318        let http = self
319            .http()
320            .unwrap_or_else(|| panic!("preset `{}` has no HTTP wire protocol", self.key));
321        crate::config::parse_protocol(http.protocol)
322            .unwrap_or_else(|| panic!("preset `{}` names an unknown protocol", self.key))
323    }
324}
325
326/// Look up a preset by its key.
327pub fn preset(key: &str) -> Option<&'static LlmPreset> {
328    PRESETS.iter().copied().find(|p| p.key == key)
329}
330
331/// Every preset key, in [`PRESETS`] order.
332pub fn preset_keys() -> Vec<&'static str> {
333    PRESETS.iter().map(|p| p.key).collect()
334}