Skip to main content

ralph_workflow/agents/opencode_api/
fetch.rs

1//! `OpenCode` API catalog fetching.
2//!
3//! This module handles HTTP requests to fetch the `OpenCode` model catalog
4//! from <https://models.dev/api.json>.
5
6use 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
12/// HTTP capability abstraction for catalog fetching.
13///
14/// Allows domain code to request HTTP bodies without importing the boundary module.
15pub trait HttpFetcher: Send + Sync {
16    /// Fetch the body of the given URL.
17    fn fetch(&self, url: &str) -> Result<String, HttpFetchError>;
18}
19
20/// Errors produced while fetching HTTP resources.
21#[derive(Debug)]
22pub enum HttpFetchError {
23    /// Underlying HTTP capability failure described by the provider.
24    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
37/// Trait for fetching the `OpenCode` API catalog.
38///
39/// This trait enables dependency injection for catalog fetching,
40/// allowing tests to provide mock implementations that don't make network calls.
41pub trait CatalogHttpClient: Send + Sync {
42    /// Fetch the API catalog JSON and parse it.
43    fn fetch_api_catalog(
44        &self,
45        ttl_seconds: u64,
46    ) -> Result<(ApiCatalog, Vec<CacheWarning>), CacheError>;
47}
48
49/// Production implementation of [`CatalogHttpClient`] that fetches from the network.
50#[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    /// Build a catalog fetcher backed by the given HTTP capability.
63    #[must_use]
64    pub fn with_fetcher(fetcher: Arc<dyn HttpFetcher>) -> Self {
65        Self { fetcher }
66    }
67
68    /// Build a catalog fetcher from any type that implements [`HttpFetcher`].
69    #[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    /// Create a mock API catalog for testing.
117    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}