Skip to main content

drep/llm/
quirks.rs

1//! What one *model* accepts, as opposed to what its provider usually does.
2//!
3//! `temperature` and `max_tokens` are properties of a model, and `drep init`
4//! used to guess them per *preset*: `kimi` sent no temperature because `k3`
5//! refuses one, and a required `max_tokens` of 200,000 because that is a number
6//! the endpoint accepts. Both guesses hold for the preset's default model and
7//! are unverified for every other model that endpoint serves - and the wizard
8//! exists so the user can pick one of those.
9//!
10//! A wrong guess is not cosmetic. A `temperature` a model rejects is a 400, and
11//! a 400 neither fails over nor retries, so the provider is configured and can
12//! never answer. The chosen model is therefore what decides.
13//!
14//! ## Why a registry, when [`crate::llm::models`] says not to
15//!
16//! That module rejected a vendored catalogue for the question *it* answers -
17//! "which models does this account's plan serve" - and the rejection stands:
18//! only the endpoint knows that, and a third-party index would go stale exactly
19//! as the hardcoded defaults did.
20//!
21//! This is a different question. `GET {base_url}/models` returns ids and
22//! nothing else: not one of the three subscription endpoints says whether a
23//! model accepts `temperature` or what its output ceiling is. The endpoint
24//! cannot answer it, so the only sources are a hand-maintained table inside
25//! drep - which is the staleness the listing removed - or an index that already
26//! tracks it. models.dev publishes both fields per model.
27//!
28//! ## Only ever narrowing
29//!
30//! The registry may **withdraw** `temperature` and may **replace** a required
31//! `max_tokens` with the model's own limit. It may never introduce either.
32//! Sending a parameter drep would otherwise have omitted is the direction that
33//! produces a 400; omitting one it would have sent costs nothing but default
34//! sampling. An index that disagrees with an endpoint therefore cannot break a
35//! provider that worked before it existed.
36//!
37//! ## Failure is never fatal
38//!
39//! Same contract as [`crate::llm::models`]: a missing cache, an unreadable one,
40//! a document that will not parse, an unreachable models.dev, a model released
41//! this morning - every one of them falls back to the preset's own values,
42//! which is what `drep init` wrote before this module existed. Nothing here can
43//! stop `drep init`.
44
45use std::collections::BTreeMap;
46use std::io::Write;
47use std::path::{Path, PathBuf};
48use std::time::Duration;
49
50use serde::{Deserialize, Serialize};
51use thiserror::Error;
52
53/// Where the raw document lives.
54pub const REGISTRY_URL: &str = "https://models.dev/api.json";
55
56/// How long to wait for it.
57///
58/// Twice [`crate::llm::models`]'s listing timeout, because the document is ~4 MB
59/// rather than a page of ids and 10 s would fail on an ordinary link. Still
60/// bounded: this runs once between two prompts, at most once a week, and every
61/// way it can fail lands on the preset's values.
62const TIMEOUT: Duration = Duration::from_secs(20);
63
64/// How old a cached registry may be before it is refetched.
65///
66/// A week. What a model accepts does not change once it has shipped, so the
67/// refresh is about models that did not exist when the cache was written.
68const MAX_AGE: u64 = 7 * 24 * 60 * 60;
69
70/// The cache file's name, under the directory [`path_from`] resolves.
71const FILE_NAME: &str = "model-quirks.toml";
72
73/// The environment variable that relocates the cache.
74pub const PATH_VAR: &str = "DREP_QUIRKS_PATH";
75
76/// What `drep init` should write for one model.
77///
78/// Built from the preset, then narrowed by the registry when it knows the
79/// model. `max_tokens_from_registry` exists so the rendered comment can say
80/// something true: "this is the model's own limit" is a claim, it lands in a
81/// file the user commits, and it is false whenever the value came from the
82/// preset's fallback instead.
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct Quirks {
85    /// Sampling temperature, or `None` to send none at all.
86    pub temperature: Option<f32>,
87    /// Completion ceiling, or `None` to send none.
88    pub max_tokens: Option<u32>,
89    /// Whether `max_tokens` is the model's own published limit.
90    pub max_tokens_from_registry: bool,
91}
92
93/// The two facts drep reads about a model.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95pub struct ModelFacts {
96    /// Whether the model accepts a `temperature` parameter at all.
97    ///
98    /// Defaulted to `true` when the document omits it - 448 of models.dev's
99    /// entries do - because withdrawing the parameter is a decision, and
100    /// silence is not evidence for it.
101    #[serde(default = "yes")]
102    pub temperature: bool,
103    /// The model's own completion ceiling, when it publishes one.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub output_limit: Option<u32>,
106}
107
108impl ModelFacts {
109    /// Keep whichever of `self` and `other` is the stricter claim, field by
110    /// field.
111    ///
112    /// Only reachable when two providers publish the same `api` URL and both
113    /// name the same model. Narrowing rather than replacing is what keeps the
114    /// registry's one guarantee - it may withdraw and it may lower, never the
115    /// reverse - from depending on which vendor id happened to sort last.
116    ///
117    /// A refused `temperature` wins over an accepted one, because omitting the
118    /// parameter costs default sampling while sending a rejected one is a 400
119    /// that neither fails over nor retries. A published limit wins over no
120    /// limit, and the lower of two published limits wins: an unknown ceiling
121    /// leaves the preset's fallback in place, which is the wider answer.
122    fn narrow(&mut self, other: Self) {
123        self.temperature = self.temperature && other.temperature;
124        self.output_limit = match (self.output_limit, other.output_limit) {
125            (Some(mine), Some(theirs)) => Some(mine.min(theirs)),
126            (mine, theirs) => mine.or(theirs),
127        };
128    }
129}
130
131/// The `#[serde(default)]` for [`ModelFacts::temperature`].
132fn yes() -> bool {
133    true
134}
135
136/// models.dev, distilled to what drep reads.
137///
138/// Keyed by endpoint rather than by the vendor's provider id, because the
139/// endpoint is what `drep.toml` carries and what the user typed. A `custom`
140/// entry pointed at a host drep ships no preset for still joins; a provider
141/// models.dev publishes with no `api` URL simply never does, and its models
142/// keep the preset's values.
143///
144/// Keying on the model id alone was never an option: one open model is served
145/// by a dozen hosts under the same name, which is the identity mistake
146/// `Provider::cache_key` already exists to avoid.
147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
148pub struct Registry {
149    /// When it was distilled, in seconds since the Unix epoch.
150    ///
151    /// First field deliberately: TOML requires every scalar in a table to
152    /// precede the sub-tables, and `providers` is nothing but sub-tables.
153    fetched_at: u64,
154    /// Normalised endpoint -> model id -> facts.
155    #[serde(default)]
156    providers: BTreeMap<String, BTreeMap<String, ModelFacts>>,
157}
158
159/// Why a registry could not be produced. Every variant is non-fatal.
160#[derive(Debug, Error)]
161pub enum QuirksError {
162    #[error("could not reach the model registry: {0}")]
163    Transport(String),
164
165    #[error("the model registry could not be read: {0}")]
166    Malformed(String),
167
168    #[error("could not write the model registry cache to {0}: {1}")]
169    Cache(PathBuf, String),
170}
171
172/// Where the wizard gets the registry.
173///
174/// A trait for the same reason [`crate::llm::models::ModelSource`] is one: the
175/// wizard's tests inject a stub, so no test in this crate reaches models.dev.
176pub trait QuirksSource {
177    /// The registry, or why there isn't one.
178    #[allow(async_fn_in_trait)]
179    async fn registry(&self) -> Result<Registry, QuirksError>;
180}
181
182/// Where the raw document comes from, underneath the cache.
183///
184/// Separate from [`QuirksSource`] so [`Cached`]'s own behaviour - freshness,
185/// writing, and what happens when the network is down but a copy is on disk -
186/// is testable with a stub in place of the network.
187pub trait Fetch {
188    /// The raw models.dev document.
189    #[allow(async_fn_in_trait)]
190    async fn document(&self) -> Result<String, QuirksError>;
191}
192
193/// A borrowed fetcher fetches.
194///
195/// So a caller can keep the fetcher and inspect it afterwards - which is how a
196/// test tells "the cache answered" from "the cache was ignored and the answer
197/// happened to match" - rather than handing ownership to [`Cached`].
198///
199/// Only [`Cached::at`] instantiates it, and that is `cfg(test)` too. Shipping
200/// it would be a public impl nothing outside the suite can reach.
201#[cfg(test)]
202impl<F: Fetch> Fetch for &F {
203    async fn document(&self) -> Result<String, QuirksError> {
204        (*self).document().await
205    }
206}
207
208/// The real thing: one HTTP GET.
209#[derive(Debug, Clone)]
210pub struct Http {
211    url: String,
212    /// The largest body this fetcher will read.
213    ///
214    /// A field rather than a bare constant so the boundary is testable: the
215    /// production value is 32 MB, and a test that had to build a body that size
216    /// to check the comparison would be the reason nobody wrote one.
217    max_bytes: u64,
218}
219
220impl Http {
221    /// A fetcher for `url` - [`REGISTRY_URL`] in production.
222    ///
223    /// The URL is a parameter rather than baked into the request for the same
224    /// reason [`crate::llm::models::Http`] takes an endpoint: a status check
225    /// against a real server is the only thing that can tell a success from a
226    /// failure, and one nothing exercises is a mutation survivor. It also lets
227    /// a caller point drep at a mirror of the document.
228    pub fn new(url: &str) -> Self {
229        Self {
230            url: url.to_string(),
231            max_bytes: MAX_DOCUMENT_BYTES,
232        }
233    }
234
235    /// The same fetcher with a different size ceiling.
236    ///
237    /// Exists for the tests that pin the boundary. Production always uses
238    /// `MAX_DOCUMENT_BYTES`, which `new` applies.
239    pub fn with_max_bytes(mut self, max_bytes: u64) -> Self {
240        self.max_bytes = max_bytes;
241        self
242    }
243}
244
245impl Fetch for Http {
246    async fn document(&self) -> Result<String, QuirksError> {
247        let client = crate::http::client(TIMEOUT).map_err(QuirksError::Transport)?;
248
249        let response = client
250            .get(&self.url)
251            .send()
252            .await
253            .map_err(|err| QuirksError::Transport(err.to_string()))?;
254
255        // Checked here rather than in `crate::http`, because what a status
256        // means is the caller's question: a 404 is an ordinary answer for a
257        // model listing and a fault for the registry. A CDN error page is
258        // valid JSON often enough that parsing it first would produce a
259        // "registry" of nothing, cached for a week.
260        if !response.status().is_success() {
261            return Err(QuirksError::Transport(format!(
262                "HTTP {}",
263                response.status().as_u16()
264            )));
265        }
266
267        crate::http::read_bounded(response, self.max_bytes)
268            .await
269            .map_err(|err| match err {
270                crate::http::ReadError::Transport(msg) => QuirksError::Transport(msg),
271                crate::http::ReadError::Malformed(msg) => QuirksError::Malformed(msg),
272            })
273    }
274}
275
276/// A registry read from disk, refetched when it has gone stale.
277///
278/// The cache path is a constructor argument rather than something read from the
279/// environment inside, for the reason `auth` has no `load_default`: a function
280/// that resolves its own path cannot be tested without writing to the process
281/// environment, and the mutation gate reports it as an undetectable survivor.
282pub struct Cached<F> {
283    path: Option<PathBuf>,
284    fetcher: F,
285    now: u64,
286}
287
288impl Cached<Http> {
289    /// Cache at `path`, fetching from models.dev when it is missing or old.
290    ///
291    /// `None` means no cache is available at all (no platform config
292    /// directory), which costs a fetch per run rather than an error.
293    pub fn new(path: Option<PathBuf>) -> Self {
294        Self {
295            path,
296            fetcher: Http::new(REGISTRY_URL),
297            now: unix_now(),
298        }
299    }
300}
301
302#[cfg(test)]
303impl<F: Fetch> Cached<F> {
304    /// [`Cached::new`] with the clock and the fetcher supplied.
305    pub(crate) fn at(path: Option<PathBuf>, fetcher: F, now: u64) -> Self {
306        Self { path, fetcher, now }
307    }
308}
309
310impl<F: Fetch> QuirksSource for Cached<F> {
311    async fn registry(&self) -> Result<Registry, QuirksError> {
312        let cached = self.path.as_deref().and_then(Registry::load);
313        if let Some(registry) = &cached
314            && !registry.is_stale(self.now)
315        {
316            return Ok(registry.clone());
317        }
318
319        let fetched = self
320            .fetcher
321            .document()
322            .await
323            .and_then(|body| Registry::distil(&body, self.now));
324
325        match fetched {
326            Ok(registry) => {
327                if let Some(path) = &self.path {
328                    // A cache drep cannot write is a slower run, not a failure:
329                    // the registry in hand is the same either way.
330                    let _ = registry.save(path);
331                }
332                Ok(registry)
333            }
334            // A stale copy still describes models that already existed, which is
335            // every model but this week's. Refusing to use it would make a user
336            // who has been offline for eight days strictly worse off than one
337            // offline for six.
338            Err(err) => cached.ok_or(err),
339        }
340    }
341}
342
343impl Registry {
344    /// What the registry knows about `model` at `endpoint`, if anything.
345    pub fn facts(&self, endpoint: &str, model: &str) -> Option<&ModelFacts> {
346        self.providers
347            .get(&crate::auth::normalise(endpoint))?
348            .get(model)
349    }
350
351    /// Whether this copy is older than `MAX_AGE` at `now`.
352    ///
353    /// A `fetched_at` in the future - a clock that moved backwards - saturates
354    /// to an age of zero and reads as fresh, rather than wrapping into a
355    /// permanent refetch.
356    pub fn is_stale(&self, now: u64) -> bool {
357        now.saturating_sub(self.fetched_at) > MAX_AGE
358    }
359
360    /// Read the cache at `path`, or `None` for any reason it cannot be used.
361    ///
362    /// Missing, unreadable and unparseable collapse deliberately: all three mean
363    /// "refetch", none of them is something a user can act on, and reporting a
364    /// corrupt cache would be an error message about a file drep is about to
365    /// overwrite anyway. That is the opposite of `AuthStore::load`, which errors
366    /// on a corrupt store - because there the file holds something irreplaceable.
367    pub fn load(path: &Path) -> Option<Self> {
368        toml::from_str(&std::fs::read_to_string(path).ok()?).ok()
369    }
370
371    /// Write the cache to `path`, creating the directory if needed.
372    pub fn save(&self, path: &Path) -> Result<(), QuirksError> {
373        let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
374        if let Some(parent) = parent {
375            // Through `auth`'s helper, not `create_dir_all`: this file shares a
376            // directory with `auth.toml`, so creating it here without narrowing
377            // it to 0700 would leave the credential store's own directory
378            // world-readable whenever `drep init` happened to cache first.
379            crate::auth::ensure_dir_private(parent)
380                .map_err(|err| QuirksError::Cache(parent.to_path_buf(), err.to_string()))?;
381        }
382        let body = toml::to_string(self)
383            .map_err(|err| QuirksError::Cache(path.to_path_buf(), err.to_string()))?;
384
385        // Written through a random, exclusively-created sibling and renamed
386        // over the target. A predictable `.tmp` name lets a planted symlink
387        // redirect `fs::write` into another file before the rename. The random
388        // sibling also isolates concurrent `drep init` runs from one another.
389        let mut temporary =
390            tempfile::NamedTempFile::new_in(parent.unwrap_or_else(|| Path::new(".")))
391                .map_err(|err| QuirksError::Cache(path.to_path_buf(), err.to_string()))?;
392        temporary
393            .write_all(body.as_bytes())
394            .map_err(|err| QuirksError::Cache(path.to_path_buf(), err.to_string()))?;
395        temporary
396            .persist(path)
397            .map(|_| ())
398            .map_err(|err| QuirksError::Cache(path.to_path_buf(), err.error.to_string()))
399    }
400
401    /// Distil models.dev's document down to what drep reads.
402    ///
403    /// The source is ~4 MB across ~190 providers and ~6,800 models; a boolean
404    /// and an integer per model is ~600 KB, measured against the real document.
405    /// A provider with no
406    /// `api` URL is dropped rather than kept under its vendor id: with no
407    /// endpoint to join on, an entry could only ever be matched by model name -
408    /// which is how one open model served by two hosts gets the other's facts.
409    pub fn distil(body: &str, fetched_at: u64) -> Result<Self, QuirksError> {
410        let raw: BTreeMap<String, RawProvider> = serde_json::from_str(body)
411            .map_err(|err| QuirksError::Malformed(crate::text::excerpt(&err.to_string(), 120)))?;
412
413        let mut providers: BTreeMap<String, BTreeMap<String, ModelFacts>> = BTreeMap::new();
414        for provider in raw.into_values() {
415            let Some(api) = provider.api.filter(|api| !api.trim().is_empty()) else {
416                continue;
417            };
418            // Merged, not inserted. Two providers can publish the same `api`
419            // URL - `minimax` and `minimax-coding-plan` both publish
420            // `https://api.minimax.io/anthropic/v1`, which is drep's own
421            // MINIMAX preset - and `insert` would silently discard whichever
422            // arrived first, taking its models with it.
423            let entry = providers.entry(crate::auth::normalise(&api)).or_default();
424            for (id, model) in provider.models {
425                let facts = ModelFacts {
426                    temperature: model.temperature,
427                    output_limit: model.limit.and_then(|limit| limit.output),
428                };
429                // Narrowed, not overwritten. Nothing says two providers sharing
430                // an `api` publish disjoint model lists, and the defaults for an
431                // omitted field are the permissive ones - `temperature: true`
432                // and no limit - so last-wins lets a sparse entry re-introduce a
433                // parameter the model rejects. The document is a map, walked in
434                // key order, so which entry lands second is an accident of the
435                // vendor id.
436                entry
437                    .entry(id)
438                    .and_modify(|held| held.narrow(facts))
439                    .or_insert(facts);
440            }
441        }
442
443        if providers.is_empty() {
444            return Err(QuirksError::Malformed(
445                "the document named no provider with an endpoint".to_string(),
446            ));
447        }
448
449        Ok(Self {
450            fetched_at,
451            providers,
452        })
453    }
454}
455
456/// What `defaults` becomes once the registry has been consulted.
457///
458/// Narrowing only, in both fields. `temperature` is withdrawn when the registry
459/// says the model refuses it and otherwise left exactly as the preset set it; a
460/// required `max_tokens` takes the model's own ceiling and an absent one stays
461/// absent. Whether the field is *required* remains a property of the endpoint,
462/// which is why `defaults.max_tokens.is_some()` still decides that `k3` gets a
463/// value and `glm-5.3` does not.
464pub fn resolve(
465    registry: Option<&Registry>,
466    defaults: Quirks,
467    endpoint: &str,
468    model: &str,
469) -> Quirks {
470    let Some(facts) = registry.and_then(|registry| registry.facts(endpoint, model)) else {
471        return defaults;
472    };
473
474    Quirks {
475        temperature: if facts.temperature {
476            defaults.temperature
477        } else {
478            None
479        },
480        // `min`, not replace. The preset's value is one drep has verified the
481        // endpoint accepts; a published limit *above* it is a claim drep has
482        // not tested, and raising a required ceiling is the direction that
483        // yields a 400 - which by invariant neither fails over nor retries.
484        // Lowering only ever costs a shorter answer.
485        max_tokens: defaults
486            .max_tokens
487            .map(|fallback| facts.output_limit.unwrap_or(fallback).min(fallback)),
488        // `<=`, not `<`. The question the rendered comment asks is whether the
489        // number written is the model's own published limit, and a model whose
490        // limit is exactly the preset's fallback answers yes - the value is
491        // both. Testing `<` reads the provenance off a comparison of the two
492        // values instead, which makes the file claim the limit is unknown
493        // while naming it exactly.
494        max_tokens_from_registry: defaults
495            .max_tokens
496            .is_some_and(|fallback| facts.output_limit.is_some_and(|limit| limit <= fallback)),
497    }
498}
499
500/// The largest registry document drep will read into memory.
501///
502/// The live document is about 4 MB. 32 MB is a wide margin for growth and still
503/// refuses a mirror, a redirect to something else, or a compromised host trying
504/// to make `drep init` allocate without bound - which the timeout alone does
505/// not prevent, since a fast host can send a great deal inside it.
506const MAX_DOCUMENT_BYTES: u64 = 32 * 1024 * 1024;
507
508/// The cache location: [`PATH_VAR`] if set, else beside `auth.toml`.
509pub fn default_path() -> Option<PathBuf> {
510    path_from(std::env::var_os(PATH_VAR))
511}
512
513/// [`default_path`] with the override supplied rather than read.
514///
515/// Split for the reason `auth::path_from` is: `std::env::set_var` is `unsafe`
516/// in edition 2024 and `cargo test` is multi-threaded, so an override read
517/// inside the function could not be tested at all.
518///
519/// The directory is `config_dir()` under the same `ProjectDirs` triple as the
520/// credential store, so drep's user-level files stay siblings under one
521/// application identity rather than scattering across two conventions. `None` -
522/// a platform with no config directory - means the run fetches and does not
523/// cache, which is slower and never wrong.
524pub fn path_from(overridden: Option<std::ffi::OsString>) -> Option<PathBuf> {
525    if let Some(path) = overridden {
526        return Some(PathBuf::from(path));
527    }
528    directories::ProjectDirs::from("dev", "slb350", "drep")
529        .map(|dirs| dirs.config_dir().join(FILE_NAME))
530}
531
532/// Seconds since the Unix epoch, or 0 for a clock set before it.
533fn unix_now() -> u64 {
534    std::time::SystemTime::now()
535        .duration_since(std::time::UNIX_EPOCH)
536        .map(|since| since.as_secs())
537        .unwrap_or(0)
538}
539
540/// One provider in models.dev's document. Every other field serde ignores.
541#[derive(Debug, Deserialize)]
542struct RawProvider {
543    #[serde(default)]
544    api: Option<String>,
545    #[serde(default)]
546    models: BTreeMap<String, RawModel>,
547}
548
549/// One model. Only the two fields drep reads are named.
550#[derive(Debug, Deserialize)]
551struct RawModel {
552    #[serde(default = "yes")]
553    temperature: bool,
554    #[serde(default)]
555    limit: Option<RawLimit>,
556}
557
558/// A model's context and completion ceilings; drep reads only the second.
559#[derive(Debug, Deserialize)]
560struct RawLimit {
561    #[serde(default)]
562    output: Option<u32>,
563}
564
565#[cfg(test)]
566mod tests;