ralph_workflow/agents/opencode_api/
fetch.rs1use crate::agents::opencode_api::cache::{save_catalog, CacheError, CacheWarning};
7use crate::agents::opencode_api::types::ApiCatalog;
8use crate::agents::opencode_api::API_URL;
9use std::fmt;
10use std::sync::Arc;
11
12pub trait HttpFetcher: Send + Sync {
16 fn fetch(&self, url: &str) -> Result<String, HttpFetchError>;
18}
19
20#[derive(Debug)]
22pub enum HttpFetchError {
23 RequestFailed(String),
25}
26
27impl fmt::Display for HttpFetchError {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 match self {
30 HttpFetchError::RequestFailed(message) => write!(f, "{message}"),
31 }
32 }
33}
34
35impl std::error::Error for HttpFetchError {}
36
37pub trait CatalogHttpClient: Send + Sync {
42 fn fetch_api_catalog(
44 &self,
45 ttl_seconds: u64,
46 ) -> Result<(ApiCatalog, Vec<CacheWarning>), CacheError>;
47}
48
49#[derive(Clone)]
51pub struct RealCatalogFetcher {
52 fetcher: Arc<dyn HttpFetcher>,
53}
54
55impl std::fmt::Debug for RealCatalogFetcher {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.debug_struct("RealCatalogFetcher").finish()
58 }
59}
60
61impl RealCatalogFetcher {
62 #[must_use]
64 pub fn with_fetcher(fetcher: Arc<dyn HttpFetcher>) -> Self {
65 Self { fetcher }
66 }
67
68 #[must_use]
70 pub fn with_http_fetcher<F>(fetcher: F) -> Self
71 where
72 F: HttpFetcher + 'static,
73 {
74 Self::with_fetcher(Arc::new(fetcher))
75 }
76}
77
78impl CatalogHttpClient for RealCatalogFetcher {
79 fn fetch_api_catalog(
80 &self,
81 ttl_seconds: u64,
82 ) -> Result<(ApiCatalog, Vec<CacheWarning>), CacheError> {
83 let json = self
84 .fetcher
85 .fetch(API_URL)
86 .map_err(|err| CacheError::FetchError(err.to_string()))?;
87
88 let catalog: ApiCatalog = serde_json::from_str(&json).map_err(CacheError::ParseError)?;
89
90 let catalog = ApiCatalog {
91 ttl_seconds,
92 cached_at: Some(chrono::Utc::now()),
93 ..catalog
94 };
95
96 let warnings: Vec<CacheWarning> = save_catalog(&catalog)
97 .err()
98 .map(|e| CacheWarning::CacheSaveFailed {
99 error: e.to_string(),
100 })
101 .into_iter()
102 .collect();
103
104 Ok((catalog, warnings))
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use crate::agents::opencode_api::types::{Model, Provider};
112 use crate::agents::opencode_api::DEFAULT_CACHE_TTL_SECONDS;
113 use std::collections::HashMap;
114 use std::fs;
115
116 pub fn mock_api_catalog() -> ApiCatalog {
118 let providers = HashMap::from([
119 (
120 "opencode".to_string(),
121 Provider {
122 id: "opencode".to_string(),
123 name: "OpenCode".to_string(),
124 description: "Open source AI coding tool".to_string(),
125 },
126 ),
127 (
128 "anthropic".to_string(),
129 Provider {
130 id: "anthropic".to_string(),
131 name: "Anthropic".to_string(),
132 description: "Anthropic Claude models".to_string(),
133 },
134 ),
135 (
136 "openai".to_string(),
137 Provider {
138 id: "openai".to_string(),
139 name: "OpenAI".to_string(),
140 description: "OpenAI GPT models".to_string(),
141 },
142 ),
143 ]);
144
145 let models = HashMap::from([
146 (
147 "opencode".to_string(),
148 vec![Model {
149 id: "glm-4.7-free".to_string(),
150 name: "GLM-4.7 Free".to_string(),
151 description: "Open source GLM model".to_string(),
152 context_length: Some(128_000),
153 }],
154 ),
155 (
156 "anthropic".to_string(),
157 vec![
158 Model {
159 id: "claude-sonnet-4-5".to_string(),
160 name: "Claude Sonnet 4.5".to_string(),
161 description: "Latest Claude Sonnet".to_string(),
162 context_length: Some(200_000),
163 },
164 Model {
165 id: "claude-opus-4".to_string(),
166 name: "Claude Opus 4".to_string(),
167 description: "Most capable Claude".to_string(),
168 context_length: Some(200_000),
169 },
170 ],
171 ),
172 (
173 "openai".to_string(),
174 vec![Model {
175 id: "gpt-4".to_string(),
176 name: "GPT-4".to_string(),
177 description: "OpenAI's GPT-4".to_string(),
178 context_length: Some(8192),
179 }],
180 ),
181 ]);
182
183 ApiCatalog {
184 providers,
185 models,
186 cached_at: Some(chrono::Utc::now()),
187 ttl_seconds: DEFAULT_CACHE_TTL_SECONDS,
188 }
189 }
190
191 #[test]
192 fn test_mock_api_catalog_structure() {
193 let catalog = mock_api_catalog();
194
195 assert_eq!(catalog.providers.len(), 3);
196 assert!(catalog.has_provider("opencode"));
197 assert!(catalog.has_provider("anthropic"));
198 assert!(catalog.has_provider("openai"));
199
200 assert!(catalog.has_model("opencode", "glm-4.7-free"));
201 assert!(catalog.has_model("anthropic", "claude-sonnet-4-5"));
202 assert!(catalog.has_model("anthropic", "claude-opus-4"));
203 assert!(catalog.has_model("openai", "gpt-4"));
204
205 let model = catalog.get_model("anthropic", "claude-sonnet-4-5").unwrap();
206 assert_eq!(model.id, "claude-sonnet-4-5");
207 assert_eq!(model.context_length, Some(200_000));
208 }
209
210 #[test]
211 fn test_catalog_ttl_default() {
212 let catalog = mock_api_catalog();
213 assert_eq!(catalog.ttl_seconds, DEFAULT_CACHE_TTL_SECONDS);
214 }
215
216 #[test]
217 fn test_api_url_constant() {
218 assert_eq!(API_URL, "https://models.dev/api.json");
219 }
220
221 #[test]
222 fn test_real_catalog_fetcher_uses_injected_http_fetcher() {
223 struct StubFetcher {
224 payload: &'static str,
225 }
226
227 impl HttpFetcher for StubFetcher {
228 fn fetch(&self, _url: &str) -> Result<String, HttpFetchError> {
229 Ok(self.payload.to_string())
230 }
231 }
232
233 let stub_catalog = r#"{
234 "test-provider": {
235 "id": "test-provider",
236 "name": "Test Provider",
237 "doc": "used for fixture",
238 "models": {
239 "test-model": {
240 "id": "test-model",
241 "name": "Test Model",
242 "family": "Lorem",
243 "limit": { "context": 4096 }
244 }
245 }
246 }
247 }"#;
248
249 let fetcher = RealCatalogFetcher::with_http_fetcher(StubFetcher {
250 payload: stub_catalog,
251 });
252
253 let (catalog, warnings) = fetcher.fetch_api_catalog(1234).unwrap();
254 assert!(warnings.is_empty());
255 assert_eq!(catalog.ttl_seconds, 1234);
256 assert!(catalog.has_provider("test-provider"));
257 assert!(catalog.has_model("test-provider", "test-model"));
258
259 cleanup_opencode_cache_file();
260 }
261
262 fn cleanup_opencode_cache_file() {
263 if let Some(cache_dir) = dirs::cache_dir() {
264 let cache_path = cache_dir.join("ralph-workflow/opencode-api-cache.json");
265 let _ = fs::remove_file(cache_path);
266 }
267 }
268}