Skip to main content

llm_kernel/discovery/
source.rs

1//! Async trait abstraction over model discovery sources.
2//!
3//! Provides a [`DiscoverySource`] trait so callers can fetch model listings from
4//! heterogeneous backends (e.g. [models.dev](https://github.com/anomalyco/models.dev))
5//! behind a single async interface.
6
7#[cfg(feature = "discovery-async")]
8mod inner {
9    use async_trait::async_trait;
10    use std::time::Duration;
11
12    use crate::error::{KernelError, Result};
13
14    /// Async source of discoverable models.
15    #[async_trait]
16    pub trait DiscoverySource: Send + Sync {
17        /// Human-readable source name.
18        fn name(&self) -> &'static str;
19        /// Discover available models from this source.
20        async fn discover(&self) -> Result<Vec<crate::discovery::ModelEntry>>;
21    }
22
23    /// Async [`DiscoverySource`] backed by a models.dev-style catalog API.
24    pub struct ModelsDevSource {
25        /// Base URL the catalog is served from (e.g. `https://models.dev`).
26        base_url: String,
27    }
28
29    impl ModelsDevSource {
30        /// Build a source pointing at the default models.dev catalog.
31        pub fn new() -> Self {
32            Self {
33                base_url: "https://models.dev".to_string(),
34            }
35        }
36
37        /// Build a source with a custom base URL (handy for tests or a self-hosted
38        /// catalog).
39        ///
40        /// **Trust boundary (SSRF):** the base URL is used verbatim. There is
41        /// no scheme or host allowlist and no private-address/loopback
42        /// blocking, so this constructor must only receive admin-configured
43        /// values — never input derived from untrusted data. Redirects are
44        /// disabled and the response body is size-capped, but a caller-chosen
45        /// URL can still be pointed directly at an internal service (e.g. a
46        /// cloud metadata endpoint), so treat the URL itself as the trust
47        /// boundary.
48        pub fn with_base_url(base_url: impl Into<String>) -> Self {
49            Self {
50                base_url: base_url.into(),
51            }
52        }
53    }
54
55    impl Default for ModelsDevSource {
56        fn default() -> Self {
57            Self::new()
58        }
59    }
60
61    #[async_trait]
62    impl DiscoverySource for ModelsDevSource {
63        fn name(&self) -> &'static str {
64            "models.dev"
65        }
66
67        async fn discover(&self) -> Result<Vec<crate::discovery::ModelEntry>> {
68            crate::tls::ensure_tls_provider();
69            let client = reqwest::Client::builder()
70                .timeout(Duration::from_secs(10))
71                // Do not follow redirects: the base URL is a trusted catalog
72                // endpoint, and a 3xx should surface as an error rather than be
73                // silently chased to an unexpected host.
74                .redirect(reqwest::redirect::Policy::none())
75                .build()
76                .map_err(KernelError::discovery)?;
77            let url = format!("{}/api.json", self.base_url.trim_end_matches('/'));
78            // Surface non-success HTTP as a clear error before any body is
79            // read, so a 4xx/5xx error page is not misread as malformed JSON.
80            let mut response = client
81                .get(&url)
82                .send()
83                .await
84                .map_err(KernelError::discovery)?
85                .error_for_status()
86                .map_err(KernelError::discovery)?;
87            // Bound the response so a malformed or hostile endpoint cannot
88            // drive unbounded memory allocation. Two layers:
89            //   1. Fast-reject via Content-Length when the server advertises it.
90            //   2. Stream the body with a hard cap, stopping the instant it is
91            //      crossed — robust against a missing or understated length.
92            const MAX_BYTES: usize = 64 * 1024 * 1024; // 64 MiB
93            if let Some(len) = response.content_length()
94                && (len as usize) > MAX_BYTES
95            {
96                return Err(KernelError::Discovery(format!(
97                    "discovery response advertised {len} bytes (cap {MAX_BYTES})"
98                )));
99            }
100            let body = read_capped_body(&mut response, MAX_BYTES).await?;
101            let payload: crate::discovery::ModelsDevPayload = serde_json::from_slice(&body)?;
102            Ok(payload.entries())
103        }
104    }
105
106    /// Reads the response body incrementally, erroring the moment its length
107    /// crosses `max_bytes`.
108    ///
109    /// Reading via [`reqwest::Response::chunk`] (rather than `Response::bytes`)
110    /// keeps peak memory bounded even when `Content-Length` is absent or
111    /// understates the true body: we stop as soon as the cap is exceeded,
112    /// before handing the bytes to the deserializer.
113    async fn read_capped_body(
114        response: &mut reqwest::Response,
115        max_bytes: usize,
116    ) -> Result<Vec<u8>> {
117        let mut buf: Vec<u8> = Vec::new();
118        while let Some(chunk) = response.chunk().await.map_err(KernelError::discovery)? {
119            if buf.len() + chunk.len() > max_bytes {
120                return Err(KernelError::Discovery(format!(
121                    "discovery response exceeded {max_bytes} bytes while streaming"
122                )));
123            }
124            buf.extend_from_slice(&chunk);
125        }
126        Ok(buf)
127    }
128}
129
130#[cfg(feature = "discovery-async")]
131pub use inner::{DiscoverySource, ModelsDevSource};
132
133#[cfg(all(test, feature = "discovery-async"))]
134mod tests {
135    use super::*;
136    use crate::discovery::{ModelEntry, ModelsDevPayload};
137
138    /// In-memory source used purely to exercise the trait without network access.
139    struct StaticSource(Vec<ModelEntry>);
140
141    #[async_trait::async_trait]
142    impl DiscoverySource for StaticSource {
143        fn name(&self) -> &'static str {
144            "static"
145        }
146
147        async fn discover(&self) -> crate::error::Result<Vec<ModelEntry>> {
148            Ok(self.0.clone())
149        }
150    }
151
152    #[tokio::test]
153    async fn test_static_source_returns_models_and_name() {
154        let entries = vec![
155            ModelEntry {
156                id: "anthropic/claude-3-5-sonnet".to_string(),
157                name: "Claude 3.5 Sonnet".to_string(),
158                provider_id: "anthropic".to_string(),
159                ..Default::default()
160            },
161            ModelEntry {
162                id: "openai/gpt-4o".to_string(),
163                name: "GPT-4o".to_string(),
164                provider_id: "openai".to_string(),
165                ..Default::default()
166            },
167        ];
168        let source = StaticSource(entries.clone());
169        assert_eq!(source.name(), "static");
170        let discovered = source.discover().await.unwrap();
171        assert_eq!(discovered.len(), entries.len());
172        assert_eq!(discovered[0].id, "anthropic/claude-3-5-sonnet");
173        assert_eq!(discovered[1].id, "openai/gpt-4o");
174    }
175
176    #[test]
177    fn test_parse_real_payload_via_models_dev_type() {
178        // Real models.dev shape: provider-keyed map with nested model objects.
179        let raw = r#"{
180            "anthropic": {
181                "id": "anthropic",
182                "env": ["ANTHROPIC_API_KEY"],
183                "models": {
184                    "claude-opus-4-5": {
185                        "id": "claude-opus-4-5",
186                        "name": "Claude Opus 4.5",
187                        "tool_call": true,
188                        "temperature": true,
189                        "limit": {"context": 200000, "output": 64000},
190                        "cost": {"input": 5, "output": 25}
191                    }
192                }
193            }
194        }"#;
195        let payload: ModelsDevPayload = serde_json::from_str(raw).unwrap();
196        let entries = payload.entries();
197        assert_eq!(entries.len(), 1);
198        assert_eq!(entries[0].id, "claude-opus-4-5");
199        assert_eq!(entries[0].provider_id, "anthropic");
200    }
201}