use crate::agents::fallback::{AgentDrain, FallbackConfig, ResolvedDrainConfig};
use crate::agents::opencode_api::ApiCatalog;
use crate::agents::opencode_resolver::OpenCodeResolver;
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OpenCodeValidationError {
InvalidReferences { messages: Vec<String> },
}
impl std::fmt::Display for OpenCodeValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidReferences { messages } => write!(f, "{}", messages.join("\n\n")),
}
}
}
impl std::error::Error for OpenCodeValidationError {}
pub fn validate_opencode_agents(
resolved: &ResolvedDrainConfig,
catalog: &ApiCatalog,
) -> Result<(), OpenCodeValidationError> {
validate_opencode_agents_in_resolved_drains(resolved, catalog)
}
pub fn validate_opencode_agents_legacy(
fallback: &FallbackConfig,
catalog: &ApiCatalog,
) -> Result<(), OpenCodeValidationError> {
validate_opencode_agents_in_resolved_drains(&fallback.resolve_drains(), catalog)
}
pub fn validate_opencode_agents_in_resolved_drains(
resolved: &ResolvedDrainConfig,
catalog: &ApiCatalog,
) -> Result<(), OpenCodeValidationError> {
let opencode_resolver = OpenCodeResolver::new(catalog.clone());
let all_agents = get_opencode_refs_in_resolved_drains(resolved);
let errors: Vec<_> = all_agents
.iter()
.filter_map(|agent_name| {
parse_opencode_ref(agent_name).and_then(|(provider, model)| {
opencode_resolver
.validate(&provider, &model)
.err()
.map(|e| opencode_resolver.format_error(&e, agent_name))
})
})
.collect();
if errors.is_empty() {
Ok(())
} else {
Err(OpenCodeValidationError::InvalidReferences { messages: errors })
}
}
fn parse_opencode_ref(agent_name: &str) -> Option<(String, String)> {
if !agent_name.starts_with("opencode/") {
return None;
}
let parts: Vec<&str> = agent_name.split('/').collect();
if parts.len() != 3 {
return None;
}
let provider = parts.get(1)?.to_string();
let model = parts.get(2)?.to_string();
Some((provider, model))
}
#[must_use]
pub fn get_opencode_refs(resolved: &ResolvedDrainConfig) -> Vec<String> {
get_opencode_refs_in_resolved_drains(resolved)
}
#[must_use]
pub fn get_opencode_refs_legacy(fallback: &FallbackConfig) -> Vec<String> {
get_opencode_refs_in_resolved_drains(&fallback.resolve_drains())
}
#[must_use]
pub fn get_opencode_refs_in_resolved_drains(resolved: &ResolvedDrainConfig) -> Vec<String> {
let unique = AgentDrain::all()
.into_iter()
.filter_map(|drain| resolved.binding(drain))
.flat_map(|binding| binding.agents.iter().cloned())
.filter(|name| name.starts_with("opencode/"))
.collect::<BTreeSet<_>>();
unique.into_iter().collect()
}
#[cfg(test)]
fn count_opencode_refs(resolved: &ResolvedDrainConfig) -> usize {
AgentDrain::all()
.into_iter()
.filter_map(|drain| resolved.binding(drain))
.flat_map(|binding| binding.agents.iter())
.filter(|name| name.starts_with("opencode/"))
.cloned()
.collect::<BTreeSet<_>>()
.len()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::opencode_api::{Model, Provider};
use std::collections::HashMap;
fn mock_catalog() -> ApiCatalog {
let providers = HashMap::from([(
"anthropic".to_string(),
Provider {
id: "anthropic".to_string(),
name: "Anthropic".to_string(),
description: "Anthropic Claude models".to_string(),
},
)]);
let models = HashMap::from([(
"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),
}],
)]);
ApiCatalog {
providers,
models,
cached_at: Some(chrono::Utc::now()),
ttl_seconds: 86400,
}
}
fn create_fallback_with_refs(refs: &[&str]) -> FallbackConfig {
FallbackConfig {
developer: refs.iter().map(|s| (*s).to_string()).collect(),
..FallbackConfig::default()
}
}
#[test]
fn test_parse_opencode_ref_valid() {
let result = parse_opencode_ref("opencode/anthropic/claude-sonnet-4-5");
assert_eq!(
result,
Some(("anthropic".to_string(), "claude-sonnet-4-5".to_string()))
);
}
#[test]
fn test_parse_opencode_ref_invalid() {
assert_eq!(parse_opencode_ref("claude"), None);
assert_eq!(parse_opencode_ref("opencode"), None);
assert_eq!(parse_opencode_ref("opencode/anthropic"), None);
assert_eq!(parse_opencode_ref("ccs/glm"), None);
}
#[test]
fn test_validate_opencode_agents_valid() {
let catalog = mock_catalog();
let fallback = create_fallback_with_refs(&["opencode/anthropic/claude-sonnet-4-5"]);
let resolved = fallback.resolve_drains();
let result = validate_opencode_agents(&resolved, &catalog);
assert!(result.is_ok());
}
#[test]
fn test_validate_opencode_agents_invalid_provider() {
let catalog = mock_catalog();
let fallback = create_fallback_with_refs(&["opencode/unknown/claude-sonnet-4-5"]);
let resolved = fallback.resolve_drains();
let result = validate_opencode_agents(&resolved, &catalog);
assert!(result.is_err());
assert!(result
.expect_err("expected invalid provider error")
.to_string()
.contains("unknown"));
}
#[test]
fn test_validate_opencode_agents_returns_typed_error() {
let catalog = mock_catalog();
let fallback = create_fallback_with_refs(&["opencode/unknown/claude-sonnet-4-5"]);
let resolved = fallback.resolve_drains();
let error = validate_opencode_agents(&resolved, &catalog)
.expect_err("invalid provider should return a typed validation error");
assert!(matches!(
error,
OpenCodeValidationError::InvalidReferences { .. }
));
}
#[test]
fn test_validate_opencode_agents_invalid_model() {
let catalog = mock_catalog();
let fallback = create_fallback_with_refs(&["opencode/anthropic/unknown-model"]);
let resolved = fallback.resolve_drains();
let result = validate_opencode_agents(&resolved, &catalog);
assert!(result.is_err());
assert!(result
.expect_err("expected invalid model error")
.to_string()
.contains("unknown-model"));
}
#[test]
fn test_count_opencode_refs() {
let fallback = create_fallback_with_refs(&[
"opencode/anthropic/claude-sonnet-4-5",
"claude",
"opencode/openai/gpt-4",
]);
let resolved = fallback.resolve_drains();
let count = count_opencode_refs(&resolved);
assert_eq!(count, 2);
}
#[test]
fn test_get_opencode_refs() {
let fallback = create_fallback_with_refs(&[
"opencode/anthropic/claude-sonnet-4-5",
"claude",
"opencode/openai/gpt-4",
]);
let resolved = fallback.resolve_drains();
let refs = get_opencode_refs(&resolved);
assert_eq!(refs.len(), 2);
assert!(refs.contains(&"opencode/anthropic/claude-sonnet-4-5".to_string()));
assert!(refs.contains(&"opencode/openai/gpt-4".to_string()));
}
}