Skip to main content

drep/llm/
models.rs

1//! Asking an endpoint which models it serves.
2//!
3//! `drep init` used to offer a hardcoded model name per preset and let the user
4//! type over it, with nothing checking the result. `presets.rs` said so out
5//! loud: the defaults are "the one thing here that goes stale". A typo, or a
6//! model the plan does not include, surfaced as a 404 on the first push rather
7//! than at the prompt.
8//!
9//! The endpoint already knows the answer, and the wizard is holding the key at
10//! exactly the moment it asks. So it asks the endpoint.
11//!
12//! ## Why not a registry
13//!
14//! A vendored catalogue would go stale exactly as the hardcoded defaults do
15//! and would additionally have to be noticed and updated. A third-party index
16//! (models.dev) covers every provider at once, but describes what a *vendor*
17//! publishes rather than what *this account's plan* serves, and it is a 4 MB
18//! network dependency on somebody else's uptime. The endpoint is authoritative
19//! for the only question being asked.
20//!
21//! ## One shape, three vendors
22//!
23//! Every endpoint drep ships a preset for answers `GET {base_url}/models` with
24//! `{"data": [{"id": ...}]}`, whichever protocol it otherwise speaks. They
25//! disagree only on what *else* is in each entry: z.ai sends OpenAI's
26//! `object`/`created`/`owned_by`, MiniMax sends Anthropic's `type`/`created_at`
27//! /`display_name`, and Kimi sends both plus `context_length` and
28//! `supports_reasoning`. Reading `id` and an optional `display_name` covers all
29//! three, and serde ignores the rest - which is also what stops a new field
30//! breaking the parse.
31//!
32//! The protocol still decides the **auth header**, because that is not
33//! negotiable per request: bearer for OpenAI-compatible, `x-api-key` plus a
34//! version for Anthropic.
35//!
36//! The configured endpoint is the exact origin allowed to receive that header.
37//! A redirect is reported like any other non-success status and is never
38//! followed, including when its destination stays on the same origin.
39//!
40//! ## Failure is never fatal
41//!
42//! A listing is a convenience during setup. An endpoint that does not serve one
43//! (a local llama.cpp build, a gateway, anything older) must leave the user
44//! typing a name exactly as before, so every error here is something the caller
45//! reports and moves past. Nothing in this module can stop `drep init`.
46
47use std::time::Duration;
48
49use open_agent::ApiProtocol;
50use serde::Deserialize;
51use thiserror::Error;
52
53/// How long to wait for a listing before giving up and letting the user type.
54///
55/// Short on purpose. This sits between two prompts in an interactive session,
56/// and a setup that appears to hang is worse than one that asks for a name.
57const TIMEOUT: Duration = Duration::from_secs(10);
58
59/// The API version header Anthropic-shaped endpoints require.
60///
61/// Duplicated from the SDK rather than imported because the SDK does not export
62/// it, and it is a one-line constant whose value is pinned by the same tests
63/// that pin the request shape. If it ever needs to change, `list` fails and the
64/// wizard falls back to typing a name.
65const ANTHROPIC_VERSION: &str = "2023-06-01";
66
67/// A model an endpoint offers.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Model {
70    /// The identifier to put in `drep.toml`.
71    pub id: String,
72    /// A human-facing name, when the endpoint sends one. Kimi's `k3` is
73    /// `"K2.7 Coding"`, which is worth showing beside the id nobody would guess
74    /// it from.
75    pub display_name: Option<String>,
76}
77
78/// The longest label the wizard prints for one model.
79///
80/// Real labels are a few dozen characters - the longest in the listings the
81/// tests carry is `MiniMax-M2.7-highspeed (MiniMax-M2.7-Highspeed)`. The bound
82/// is for an endpoint that sends far more than that, not for any of them.
83const LABEL_MAX: usize = 120;
84
85impl Model {
86    /// How the wizard lists this model: the id, plus the vendor's own name for
87    /// it when that differs.
88    ///
89    /// Through [`crate::text::excerpt`], because both halves are the
90    /// endpoint's text and this goes straight to the terminal. Parsing the
91    /// listing already refuses an id with a control character in it, but
92    /// `display_name` is shown, never stored, so it is cleaned here rather
93    /// than refused there.
94    pub fn label(&self) -> String {
95        let label = match &self.display_name {
96            Some(name) if name != &self.id => format!("{} ({name})", self.id),
97            _ => self.id.clone(),
98        };
99        crate::text::excerpt(&label, LABEL_MAX)
100    }
101}
102
103/// Why a listing could not be produced.
104///
105/// Every variant is non-fatal: the caller reports it and asks for a name.
106#[derive(Debug, Error)]
107pub enum ListError {
108    #[error("this endpoint does not offer a model list")]
109    Unsupported,
110
111    #[error("the endpoint rejected the key (HTTP {0})")]
112    Unauthorized(u16),
113
114    #[error("could not reach the endpoint: {0}")]
115    Transport(String),
116
117    #[error("the endpoint's model list could not be read: {0}")]
118    Malformed(String),
119}
120
121/// Where the wizard gets a model list.
122///
123/// A trait so the wizard can be driven by a stub: the alternative is a wizard
124/// test suite that makes real network calls, which would be slow, offline-
125/// hostile, and dependent on somebody's plan still including a given model.
126pub trait ModelSource {
127    /// List the models `endpoint` serves.
128    #[allow(async_fn_in_trait)]
129    async fn list(
130        &self,
131        endpoint: &str,
132        api_key: &str,
133        protocol: ApiProtocol,
134    ) -> Result<Vec<Model>, ListError>;
135}
136
137/// The largest listing drep will read into memory.
138///
139/// A real listing is a few kilobytes - the longest of the four is Kimi's, at
140/// well under one. 8 MB is a margin no honest endpoint approaches, and it is
141/// what stops a mirror, a redirect to something else, or a compromised host
142/// making `drep init` allocate without bound. The timeout does not prevent
143/// that on its own: a fast host can send a great deal inside one.
144const MAX_LISTING_BYTES: u64 = 8 * 1024 * 1024;
145
146/// The real thing: one HTTP GET.
147#[derive(Debug, Clone, Copy)]
148pub struct Http {
149    /// The ceiling on the response body. A field rather than a constant read
150    /// directly, so the boundary is reachable from a test without a multi-
151    /// megabyte fixture - the same reason [`crate::llm::quirks::Http`] has one.
152    max_bytes: u64,
153}
154
155impl Http {
156    /// A fetcher with the production ceiling.
157    pub fn new() -> Self {
158        Self {
159            max_bytes: MAX_LISTING_BYTES,
160        }
161    }
162
163    /// The same fetcher with a different size ceiling. For the tests that pin
164    /// the boundary; production always uses [`MAX_LISTING_BYTES`].
165    #[cfg(test)]
166    pub fn with_max_bytes(mut self, max_bytes: u64) -> Self {
167        self.max_bytes = max_bytes;
168        self
169    }
170}
171
172impl Default for Http {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178impl ModelSource for Http {
179    async fn list(
180        &self,
181        endpoint: &str,
182        api_key: &str,
183        protocol: ApiProtocol,
184    ) -> Result<Vec<Model>, ListError> {
185        let client = crate::http::client(TIMEOUT).map_err(ListError::Transport)?;
186
187        let request = client.get(url(endpoint));
188        let request = match protocol {
189            ApiProtocol::Anthropic => request
190                .header("x-api-key", api_key)
191                .header("anthropic-version", ANTHROPIC_VERSION),
192            // `ApiProtocol` is `#[non_exhaustive]`, so a protocol added to the
193            // SDK later lands here. Bearer is the right guess for anything
194            // OpenAI-shaped, and a wrong one costs a fallback to typing a name.
195            _ => request.header("Authorization", format!("Bearer {api_key}")),
196        };
197
198        let response = request
199            .send()
200            .await
201            .map_err(|err| ListError::Transport(err.to_string()))?;
202
203        let status = response.status().as_u16();
204        if !response.status().is_success() {
205            return Err(classify(status));
206        }
207
208        // Bounded, not `text()`. This is an endpoint the user typed at a
209        // prompt, and drep is holding a key while it asks - so the body has a
210        // ceiling for the same reason the registry document does.
211        let body = crate::http::read_bounded(response, self.max_bytes)
212            .await
213            .map_err(|err| match err {
214                crate::http::ReadError::Transport(msg) => ListError::Transport(msg),
215                crate::http::ReadError::Malformed(msg) => ListError::Malformed(msg),
216            })?;
217        parse(&body)
218    }
219}
220
221/// The listing URL for `endpoint`.
222///
223/// `{base_url}/models` for both protocols - verified against all three
224/// subscription endpoints, whose base URLs already carry whatever version
225/// segment they use (`/api/coding/paas/v4`, `/anthropic/v1`, `/coding/v1`).
226/// A trailing slash on the configured endpoint would otherwise produce `//`,
227/// which some gateways answer with a redirect and others with a 404.
228fn url(endpoint: &str) -> String {
229    format!("{}/models", endpoint.trim_end_matches('/'))
230}
231
232/// Map an HTTP status onto the reason the caller reports.
233///
234/// 404 and 405 are the endpoint saying it has no such route, which is the
235/// ordinary case for a local server rather than a fault. 401 and 403 are worth
236/// separating because they mean the key is wrong - the user is about to store
237/// it, and finding out now beats finding out on the first push.
238fn classify(status: u16) -> ListError {
239    match status {
240        404 | 405 | 501 => ListError::Unsupported,
241        401 | 403 => ListError::Unauthorized(status),
242        other => ListError::Transport(format!("HTTP {other}")),
243    }
244}
245
246/// The half of a listing response drep reads.
247#[derive(Debug, Deserialize)]
248struct Listing {
249    data: Vec<Entry>,
250}
251
252/// One entry. Every other field the vendors send is ignored by serde.
253#[derive(Debug, Deserialize)]
254struct Entry {
255    id: String,
256    #[serde(default)]
257    display_name: Option<String>,
258}
259
260/// Parse a listing body into models, in the order the endpoint sent them.
261///
262/// Order is preserved rather than sorted: every one of these endpoints lists
263/// its newest model first, which is the one a user setting drep up almost
264/// always wants, and alphabetical order would bury it (`MiniMax-M2` sorts above
265/// `MiniMax-M3`; `glm-4.5` above `glm-5.3`).
266///
267/// An empty list is [`ListError::Unsupported`] rather than an empty menu: a
268/// prompt offering nothing is worse than the free-text prompt it replaced.
269///
270/// An id with a control character in it is dropped, not cleaned. The id is
271/// what the user's choice writes into `drep.toml`, so a cleaned copy in the
272/// menu would show one name while the config held another, and the raw one
273/// would reach every later report of that provider. No honest endpoint serves
274/// a model whose name carries an escape sequence.
275fn parse(body: &str) -> Result<Vec<Model>, ListError> {
276    let listing: Listing = serde_json::from_str(body)
277        .map_err(|err| ListError::Malformed(crate::text::excerpt(&err.to_string(), 120)))?;
278
279    let models: Vec<Model> = listing
280        .data
281        .into_iter()
282        .filter(|entry| !entry.id.is_empty() && !entry.id.chars().any(char::is_control))
283        .map(|entry| Model {
284            id: entry.id,
285            display_name: entry.display_name,
286        })
287        .collect();
288
289    if models.is_empty() {
290        return Err(ListError::Unsupported);
291    }
292    Ok(models)
293}
294
295#[cfg(test)]
296mod tests;