use crate::agents::opencode_api::cache::{save_catalog, CacheError, CacheWarning};
use crate::agents::opencode_api::types::ApiCatalog;
use crate::agents::opencode_api::API_URL;
use std::fmt;
use std::sync::Arc;
pub trait HttpFetcher: Send + Sync {
fn fetch(&self, url: &str) -> Result<String, HttpFetchError>;
}
#[derive(Debug)]
pub enum HttpFetchError {
RequestFailed(String),
}
impl fmt::Display for HttpFetchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HttpFetchError::RequestFailed(message) => write!(f, "{message}"),
}
}
}
impl std::error::Error for HttpFetchError {}
pub trait CatalogHttpClient: Send + Sync {
fn fetch_api_catalog(
&self,
ttl_seconds: u64,
) -> Result<(ApiCatalog, Vec<CacheWarning>), CacheError>;
}
#[derive(Clone)]
pub struct RealCatalogFetcher {
fetcher: Arc<dyn HttpFetcher>,
}
impl std::fmt::Debug for RealCatalogFetcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RealCatalogFetcher").finish()
}
}
impl RealCatalogFetcher {
#[must_use]
pub fn with_fetcher(fetcher: Arc<dyn HttpFetcher>) -> Self {
Self { fetcher }
}
#[must_use]
pub fn with_http_fetcher<F>(fetcher: F) -> Self
where
F: HttpFetcher + 'static,
{
Self::with_fetcher(Arc::new(fetcher))
}
}
impl CatalogHttpClient for RealCatalogFetcher {
fn fetch_api_catalog(
&self,
ttl_seconds: u64,
) -> Result<(ApiCatalog, Vec<CacheWarning>), CacheError> {
let json = self
.fetcher
.fetch(API_URL)
.map_err(|err| CacheError::FetchError(err.to_string()))?;
let catalog: ApiCatalog = serde_json::from_str(&json).map_err(CacheError::ParseError)?;
let catalog = ApiCatalog {
ttl_seconds,
cached_at: Some(chrono::Utc::now()),
..catalog
};
let warnings: Vec<CacheWarning> = save_catalog(&catalog)
.err()
.map(|e| CacheWarning::CacheSaveFailed {
error: e.to_string(),
})
.into_iter()
.collect();
Ok((catalog, warnings))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::opencode_api::types::{Model, Provider};
use crate::agents::opencode_api::DEFAULT_CACHE_TTL_SECONDS;
use std::collections::HashMap;
use std::fs;
pub fn mock_api_catalog() -> ApiCatalog {
let providers = HashMap::from([
(
"opencode".to_string(),
Provider {
id: "opencode".to_string(),
name: "OpenCode".to_string(),
description: "Open source AI coding tool".to_string(),
},
),
(
"anthropic".to_string(),
Provider {
id: "anthropic".to_string(),
name: "Anthropic".to_string(),
description: "Anthropic Claude models".to_string(),
},
),
(
"openai".to_string(),
Provider {
id: "openai".to_string(),
name: "OpenAI".to_string(),
description: "OpenAI GPT models".to_string(),
},
),
]);
let models = HashMap::from([
(
"opencode".to_string(),
vec![Model {
id: "glm-4.7-free".to_string(),
name: "GLM-4.7 Free".to_string(),
description: "Open source GLM model".to_string(),
context_length: Some(128_000),
}],
),
(
"anthropic".to_string(),
vec![
Model {
id: "claude-sonnet-4-5".to_string(),
name: "Claude Sonnet 4.5".to_string(),
description: "Latest Claude Sonnet".to_string(),
context_length: Some(200_000),
},
Model {
id: "claude-opus-4".to_string(),
name: "Claude Opus 4".to_string(),
description: "Most capable Claude".to_string(),
context_length: Some(200_000),
},
],
),
(
"openai".to_string(),
vec![Model {
id: "gpt-4".to_string(),
name: "GPT-4".to_string(),
description: "OpenAI's GPT-4".to_string(),
context_length: Some(8192),
}],
),
]);
ApiCatalog {
providers,
models,
cached_at: Some(chrono::Utc::now()),
ttl_seconds: DEFAULT_CACHE_TTL_SECONDS,
}
}
#[test]
fn test_mock_api_catalog_structure() {
let catalog = mock_api_catalog();
assert_eq!(catalog.providers.len(), 3);
assert!(catalog.has_provider("opencode"));
assert!(catalog.has_provider("anthropic"));
assert!(catalog.has_provider("openai"));
assert!(catalog.has_model("opencode", "glm-4.7-free"));
assert!(catalog.has_model("anthropic", "claude-sonnet-4-5"));
assert!(catalog.has_model("anthropic", "claude-opus-4"));
assert!(catalog.has_model("openai", "gpt-4"));
let model = catalog.get_model("anthropic", "claude-sonnet-4-5").unwrap();
assert_eq!(model.id, "claude-sonnet-4-5");
assert_eq!(model.context_length, Some(200_000));
}
#[test]
fn test_catalog_ttl_default() {
let catalog = mock_api_catalog();
assert_eq!(catalog.ttl_seconds, DEFAULT_CACHE_TTL_SECONDS);
}
#[test]
fn test_api_url_constant() {
assert_eq!(API_URL, "https://models.dev/api.json");
}
#[test]
fn test_real_catalog_fetcher_uses_injected_http_fetcher() {
struct StubFetcher {
payload: &'static str,
}
impl HttpFetcher for StubFetcher {
fn fetch(&self, _url: &str) -> Result<String, HttpFetchError> {
Ok(self.payload.to_string())
}
}
let stub_catalog = r#"{
"test-provider": {
"id": "test-provider",
"name": "Test Provider",
"doc": "used for fixture",
"models": {
"test-model": {
"id": "test-model",
"name": "Test Model",
"family": "Lorem",
"limit": { "context": 4096 }
}
}
}
}"#;
let fetcher = RealCatalogFetcher::with_http_fetcher(StubFetcher {
payload: stub_catalog,
});
let (catalog, warnings) = fetcher.fetch_api_catalog(1234).unwrap();
assert!(warnings.is_empty());
assert_eq!(catalog.ttl_seconds, 1234);
assert!(catalog.has_provider("test-provider"));
assert!(catalog.has_model("test-provider", "test-model"));
cleanup_opencode_cache_file();
}
fn cleanup_opencode_cache_file() {
if let Some(cache_dir) = dirs::cache_dir() {
let cache_path = cache_dir.join("ralph-workflow/opencode-api-cache.json");
let _ = fs::remove_file(cache_path);
}
}
}