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