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            let client = reqwest::Client::builder()
69                .timeout(Duration::from_secs(10))
70                // Do not follow redirects: the base URL is a trusted catalog
71                // endpoint, and a 3xx should surface as an error rather than be
72                // silently chased to an unexpected host.
73                .redirect(reqwest::redirect::Policy::none())
74                .build()
75                .map_err(KernelError::discovery)?;
76            let url = format!("{}/api.json", self.base_url.trim_end_matches('/'));
77            // Surface non-success HTTP as a clear error before any body is
78            // read, so a 4xx/5xx error page is not misread as malformed JSON.
79            let mut response = client
80                .get(&url)
81                .send()
82                .await
83                .map_err(KernelError::discovery)?
84                .error_for_status()
85                .map_err(KernelError::discovery)?;
86            // Bound the response so a malformed or hostile endpoint cannot
87            // drive unbounded memory allocation. Two layers:
88            //   1. Fast-reject via Content-Length when the server advertises it.
89            //   2. Stream the body with a hard cap, stopping the instant it is
90            //      crossed — robust against a missing or understated length.
91            const MAX_BYTES: usize = 64 * 1024 * 1024; // 64 MiB
92            if let Some(len) = response.content_length()
93                && (len as usize) > MAX_BYTES
94            {
95                return Err(KernelError::Discovery(format!(
96                    "discovery response advertised {len} bytes (cap {MAX_BYTES})"
97                )));
98            }
99            let body = read_capped_body(&mut response, MAX_BYTES).await?;
100            let payload: crate::discovery::ModelsDevPayload = serde_json::from_slice(&body)?;
101            Ok(payload.entries())
102        }
103    }
104
105    /// Reads the response body incrementally, erroring the moment its length
106    /// crosses `max_bytes`.
107    ///
108    /// Reading via [`reqwest::Response::chunk`] (rather than `Response::bytes`)
109    /// keeps peak memory bounded even when `Content-Length` is absent or
110    /// understates the true body: we stop as soon as the cap is exceeded,
111    /// before handing the bytes to the deserializer.
112    async fn read_capped_body(
113        response: &mut reqwest::Response,
114        max_bytes: usize,
115    ) -> Result<Vec<u8>> {
116        let mut buf: Vec<u8> = Vec::new();
117        while let Some(chunk) = response.chunk().await.map_err(KernelError::discovery)? {
118            if buf.len() + chunk.len() > max_bytes {
119                return Err(KernelError::Discovery(format!(
120                    "discovery response exceeded {max_bytes} bytes while streaming"
121                )));
122            }
123            buf.extend_from_slice(&chunk);
124        }
125        Ok(buf)
126    }
127}
128
129#[cfg(feature = "discovery-async")]
130pub use inner::{DiscoverySource, ModelsDevSource};
131
132#[cfg(all(test, feature = "discovery-async"))]
133mod tests {
134    use super::*;
135    use crate::discovery::{ModelEntry, ModelsDevPayload};
136
137    /// In-memory source used purely to exercise the trait without network access.
138    struct StaticSource(Vec<ModelEntry>);
139
140    #[async_trait::async_trait]
141    impl DiscoverySource for StaticSource {
142        fn name(&self) -> &'static str {
143            "static"
144        }
145
146        async fn discover(&self) -> crate::error::Result<Vec<ModelEntry>> {
147            Ok(self.0.clone())
148        }
149    }
150
151    #[tokio::test]
152    async fn test_static_source_returns_models_and_name() {
153        let entries = vec![
154            ModelEntry {
155                id: "anthropic/claude-3-5-sonnet".to_string(),
156                name: "Claude 3.5 Sonnet".to_string(),
157                provider_id: "anthropic".to_string(),
158                ..Default::default()
159            },
160            ModelEntry {
161                id: "openai/gpt-4o".to_string(),
162                name: "GPT-4o".to_string(),
163                provider_id: "openai".to_string(),
164                ..Default::default()
165            },
166        ];
167        let source = StaticSource(entries.clone());
168        assert_eq!(source.name(), "static");
169        let discovered = source.discover().await.unwrap();
170        assert_eq!(discovered.len(), entries.len());
171        assert_eq!(discovered[0].id, "anthropic/claude-3-5-sonnet");
172        assert_eq!(discovered[1].id, "openai/gpt-4o");
173    }
174
175    #[test]
176    fn test_parse_real_payload_via_models_dev_type() {
177        // Real models.dev shape: provider-keyed map with nested model objects.
178        let raw = r#"{
179            "anthropic": {
180                "id": "anthropic",
181                "env": ["ANTHROPIC_API_KEY"],
182                "models": {
183                    "claude-opus-4-5": {
184                        "id": "claude-opus-4-5",
185                        "name": "Claude Opus 4.5",
186                        "tool_call": true,
187                        "temperature": true,
188                        "limit": {"context": 200000, "output": 64000},
189                        "cost": {"input": 5, "output": 25}
190                    }
191                }
192            }
193        }"#;
194        let payload: ModelsDevPayload = serde_json::from_str(raw).unwrap();
195        let entries = payload.entries();
196        assert_eq!(entries.len(), 1);
197        assert_eq!(entries[0].id, "claude-opus-4-5");
198        assert_eq!(entries[0].provider_id, "anthropic");
199    }
200}