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