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 let client = reqwest::Client::builder()
69 .timeout(Duration::from_secs(10))
70 .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 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 const MAX_BYTES: usize = 64 * 1024 * 1024; 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 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 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 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}