Skip to main content

lean_ctx/core/
hive_models.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The seam through which the local proxy serves the subscription models
3//! (mz6, `docs/model-access-subscription.md` §4).
4//!
5//! The built-in registry provider [`PROVIDER_ID`] forwards to the Hive models
6//! gateway with the instance's short-lived `hive_i_` access token. The proxy
7//! and the Hive connector are sibling crates, so the connector registers the
8//! one process-wide [`HiveTokenSource`] (as it does the runtime
9//! `LeaseSource`) and the proxy asks it, never the Hive crate directly.
10//!
11//! The token opens every engine route of Hive, so it may only go to the
12//! models origin [`models_origin`] derives from the enrolled Hive URL.
13
14use std::sync::{Arc, RwLock};
15
16/// Registry id of the built-in subscription-model provider.
17pub const PROVIDER_ID: &str = "leanctx";
18
19/// Error code of the local 403 while the lease does not grant the right
20/// `models.subscription` (or the models origin is refused).
21pub const UNAVAILABLE_CODE: &str = "subscription_models_unavailable";
22
23/// Error code of the local 503 when no instance token can be had.
24pub const TOKEN_UNAVAILABLE_CODE: &str = "hive_token_unavailable";
25
26/// What a request to the gateway may carry now.
27pub struct ModelsAdmission {
28    /// The models origin (`https://models.<hive host>`), no trailing slash.
29    pub origin: String,
30    /// The instance access token (`hive_i_…`).
31    pub token: zeroize::Zeroizing<String>,
32    /// A held route lets this one request through as a probe: its success
33    /// must be reported ([`HiveTokenSource::note_passed`]) to end the hold.
34    pub probe: bool,
35}
36
37// The token is a credential: debug output never shows it.
38impl std::fmt::Debug for ModelsAdmission {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct("ModelsAdmission")
41            .field("origin", &self.origin)
42            .field("token", &"[redacted]")
43            .field("probe", &self.probe)
44            .finish()
45    }
46}
47
48/// Why a request is answered locally instead of going to the gateway.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum ModelsRefusal {
51    /// Not connected, Hive paused, revoked, lease lapsed or without the
52    /// right `models.subscription`: 403 [`UNAVAILABLE_CODE`].
53    Unavailable,
54    /// The configured `[hive] models_url` is not on the Hive host: 403
55    /// [`UNAVAILABLE_CODE`]; checked before any token is read or minted.
56    OriginRefused(String),
57    /// An earlier gateway refusal (402/403/429) holds the `leanctx` routes
58    /// until `retry_after_s` from now (throttle style).
59    Held {
60        status: u16,
61        code: String,
62        retry_after_s: u64,
63        line: String,
64    },
65    /// No token could be minted: 503 [`TOKEN_UNAVAILABLE_CODE`].
66    TokenUnavailable,
67}
68
69/// Where the subscription-model access comes from (the Hive connector).
70/// Every method may block (file reads, a token mint); the proxy calls them
71/// off the async runtime and bounds the wait.
72pub trait HiveTokenSource: Send + Sync {
73    /// Lease right, origin pin, holds, then the token; `fresh_token` mints
74    /// a new one (the gateway answered 401 to the cached one).
75    fn admit_route(&self, fresh_token: bool) -> Result<ModelsAdmission, ModelsRefusal>;
76    /// The gateway answered `status` (non-2xx) with `body`: record the hold
77    /// it sets, if any.
78    fn note_answer(&self, status: u16, retry_after: Option<&str>, body: &[u8]);
79    /// A probe passed: the holds of the `leanctx` routes end.
80    fn note_passed(&self);
81}
82
83static SOURCE: RwLock<Option<Arc<dyn HiveTokenSource>>> = RwLock::new(None);
84
85/// Registers the process-wide source. A later call replaces it.
86pub fn set_hive_token_source(source: Arc<dyn HiveTokenSource>) {
87    *SOURCE
88        .write()
89        .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(source);
90}
91
92/// The registered source; `None` without a Hive connector.
93pub fn hive_token_source() -> Option<Arc<dyn HiveTokenSource>> {
94    SOURCE
95        .read()
96        .unwrap_or_else(std::sync::PoisonError::into_inner)
97        .clone()
98}
99
100fn is_loopback(host: &str) -> bool {
101    let host = host.trim_start_matches('[').trim_end_matches(']');
102    host == "localhost"
103        || host
104            .parse::<std::net::IpAddr>()
105            .is_ok_and(|ip| ip.is_loopback())
106}
107
108fn origin_of(url: &reqwest::Url) -> String {
109    url.origin().ascii_serialization()
110}
111
112/// The models origin for an instance enrolled at `hive_url`.
113///
114/// Without `models_url` it is `https://models.<hive host>` (a loopback Hive,
115/// as in tests, serves the models itself). A configured `models_url` is
116/// honoured only when it is a bare origin whose host equals the Hive host or
117/// is a direct subdomain of it, over HTTPS; loopback is allowed for tests.
118/// Anything else is refused: the token must never reach a foreign host.
119pub fn models_origin(hive_url: &str, models_url: Option<&str>) -> Result<String, String> {
120    let hive = reqwest::Url::parse(hive_url.trim()).map_err(|e| format!("Hive URL: {e}"))?;
121    let hive_host = hive
122        .host_str()
123        .ok_or("Hive URL has no host")?
124        .to_ascii_lowercase();
125    let Some(raw) = models_url.map(str::trim).filter(|s| !s.is_empty()) else {
126        if is_loopback(&hive_host) {
127            return Ok(origin_of(&hive));
128        }
129        if hive.scheme() != "https" {
130            return Err("Hive URL is not https".into());
131        }
132        let port = hive.port().map(|p| format!(":{p}")).unwrap_or_default();
133        return Ok(format!("https://models.{hive_host}{port}"));
134    };
135    let url = reqwest::Url::parse(raw).map_err(|e| format!("models_url: {e}"))?;
136    if !matches!(url.path(), "" | "/")
137        || url.query().is_some()
138        || url.fragment().is_some()
139        || !url.username().is_empty()
140        || url.password().is_some()
141    {
142        return Err("models_url must be a bare origin (scheme, host, port)".into());
143    }
144    let host = url
145        .host_str()
146        .ok_or("models_url has no host")?
147        .to_ascii_lowercase();
148    if is_loopback(&host) && matches!(url.scheme(), "http" | "https") {
149        return Ok(origin_of(&url));
150    }
151    if url.scheme() != "https" {
152        return Err("models_url must use https".into());
153    }
154    let direct_subdomain = host
155        .strip_suffix(&hive_host)
156        .and_then(|rest| rest.strip_suffix('.'))
157        .is_some_and(|label| !label.is_empty() && !label.contains('.'));
158    if host == hive_host || direct_subdomain {
159        Ok(origin_of(&url))
160    } else {
161        Err(format!(
162            "models_url host {host} is not the Hive host {hive_host} or a direct subdomain of it"
163        ))
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn the_models_origin_stays_on_the_hive_host() {
173        let hive = "https://hive.example.com";
174        assert_eq!(
175            models_origin(hive, None).unwrap(),
176            "https://models.hive.example.com"
177        );
178        for ok in [
179            "https://models.hive.example.com",
180            "https://gw.hive.example.com/",
181            "https://hive.example.com:8443",
182            "http://127.0.0.1:9000",
183        ] {
184            assert!(
185                models_origin(hive, Some(ok)).is_ok(),
186                "{ok} must be honoured"
187            );
188        }
189        for refused in [
190            "https://models.evil.example",
191            "https://hive.example.com.evil.example",
192            "https://a.b.hive.example.com",
193            "https://evilhive.example.com",
194            "http://models.hive.example.com",
195            "https://models.hive.example.com/v1",
196            "https://user@models.hive.example.com",
197        ] {
198            assert!(
199                models_origin(hive, Some(refused)).is_err(),
200                "{refused} must be refused"
201            );
202        }
203        assert_eq!(
204            models_origin("http://127.0.0.1:7000", None).unwrap(),
205            "http://127.0.0.1:7000"
206        );
207        assert!(models_origin("http://hive.example.com", None).is_err());
208    }
209}