llm_kernel/discovery/
source.rs1#[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_trait]
16 pub trait DiscoverySource: Send + Sync {
17 fn name(&self) -> &'static str;
19 async fn discover(&self) -> Result<Vec<crate::discovery::ModelEntry>>;
21 }
22
23 pub struct ModelsDevSource {
25 base_url: String,
27 }
28
29 impl ModelsDevSource {
30 pub fn new() -> Self {
32 Self {
33 base_url: "https://models.dev".to_string(),
34 }
35 }
36
37 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 .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 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 const MAX_BYTES: usize = 64 * 1024 * 1024; 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 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 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 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}