ralph_workflow/agents/
validation.rs1use crate::agents::fallback::{AgentDrain, FallbackConfig, ResolvedDrainConfig};
11use crate::agents::opencode_api::ApiCatalog;
12use crate::agents::opencode_resolver::OpenCodeResolver;
13use std::collections::BTreeSet;
14
15pub fn validate_opencode_agents(
27 resolved: &ResolvedDrainConfig,
28 catalog: &ApiCatalog,
29) -> Result<(), String> {
30 validate_opencode_agents_in_resolved_drains(resolved, catalog)
31}
32
33pub fn validate_opencode_agents_legacy(
41 fallback: &FallbackConfig,
42 catalog: &ApiCatalog,
43) -> Result<(), String> {
44 validate_opencode_agents_in_resolved_drains(&fallback.resolve_drains(), catalog)
45}
46
47pub fn validate_opencode_agents_in_resolved_drains(
53 resolved: &ResolvedDrainConfig,
54 catalog: &ApiCatalog,
55) -> Result<(), String> {
56 let opencode_resolver = OpenCodeResolver::new(catalog.clone());
57 let mut errors = Vec::new();
58
59 let all_agents = get_opencode_refs_in_resolved_drains(resolved);
60
61 for agent_name in all_agents {
63 if let Some((provider, model)) = parse_opencode_ref(&agent_name) {
64 if let Err(e) = opencode_resolver.validate(&provider, &model) {
65 let msg = opencode_resolver.format_error(&e, &agent_name);
66 errors.push(msg);
67 }
68 }
69 }
70
71 if errors.is_empty() {
72 Ok(())
73 } else {
74 Err(errors.join("\n\n"))
75 }
76}
77
78fn parse_opencode_ref(agent_name: &str) -> Option<(String, String)> {
82 if !agent_name.starts_with("opencode/") {
83 return None;
84 }
85
86 let parts: Vec<&str> = agent_name.split('/').collect();
87 if parts.len() != 3 {
88 return None;
89 }
90
91 let provider = parts[1].to_string();
92 let model = parts[2].to_string();
93
94 Some((provider, model))
95}
96
97#[must_use]
99pub fn get_opencode_refs(resolved: &ResolvedDrainConfig) -> Vec<String> {
100 get_opencode_refs_in_resolved_drains(resolved)
101}
102
103#[must_use]
105pub fn get_opencode_refs_legacy(fallback: &FallbackConfig) -> Vec<String> {
106 get_opencode_refs_in_resolved_drains(&fallback.resolve_drains())
107}
108
109#[must_use]
111pub fn get_opencode_refs_in_resolved_drains(resolved: &ResolvedDrainConfig) -> Vec<String> {
112 let unique = AgentDrain::all()
113 .into_iter()
114 .filter_map(|drain| resolved.binding(drain))
115 .flat_map(|binding| binding.agents.iter().cloned())
116 .filter(|name| name.starts_with("opencode/"))
117 .collect::<BTreeSet<_>>();
118
119 unique.into_iter().collect()
120}
121
122#[cfg(test)]
124fn count_opencode_refs(resolved: &ResolvedDrainConfig) -> usize {
125 AgentDrain::all()
126 .into_iter()
127 .filter_map(|drain| resolved.binding(drain))
128 .flat_map(|binding| binding.agents.iter())
129 .filter(|name| name.starts_with("opencode/"))
130 .cloned()
131 .collect::<BTreeSet<_>>()
132 .len()
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use crate::agents::opencode_api::{Model, Provider};
139 use std::collections::HashMap;
140
141 fn mock_catalog() -> ApiCatalog {
142 let mut providers = HashMap::new();
143 providers.insert(
144 "anthropic".to_string(),
145 Provider {
146 id: "anthropic".to_string(),
147 name: "Anthropic".to_string(),
148 description: "Anthropic Claude models".to_string(),
149 },
150 );
151
152 let mut models = HashMap::new();
153 models.insert(
154 "anthropic".to_string(),
155 vec![Model {
156 id: "claude-sonnet-4-5".to_string(),
157 name: "Claude Sonnet 4.5".to_string(),
158 description: "Latest Claude Sonnet".to_string(),
159 context_length: Some(200_000),
160 }],
161 );
162
163 ApiCatalog {
164 providers,
165 models,
166 cached_at: Some(chrono::Utc::now()),
167 ttl_seconds: 86400,
168 }
169 }
170
171 fn create_fallback_with_refs(refs: &[&str]) -> FallbackConfig {
172 FallbackConfig {
173 developer: refs.iter().map(|s| (*s).to_string()).collect(),
174 ..FallbackConfig::default()
175 }
176 }
177
178 #[test]
179 fn test_parse_opencode_ref_valid() {
180 let result = parse_opencode_ref("opencode/anthropic/claude-sonnet-4-5");
181 assert_eq!(
182 result,
183 Some(("anthropic".to_string(), "claude-sonnet-4-5".to_string()))
184 );
185 }
186
187 #[test]
188 fn test_parse_opencode_ref_invalid() {
189 assert_eq!(parse_opencode_ref("claude"), None);
190 assert_eq!(parse_opencode_ref("opencode"), None);
191 assert_eq!(parse_opencode_ref("opencode/anthropic"), None);
192 assert_eq!(parse_opencode_ref("ccs/glm"), None);
193 }
194
195 #[test]
196 fn test_validate_opencode_agents_valid() {
197 let catalog = mock_catalog();
198 let fallback = create_fallback_with_refs(&["opencode/anthropic/claude-sonnet-4-5"]);
199 let resolved = fallback.resolve_drains();
200
201 let result = validate_opencode_agents(&resolved, &catalog);
202 assert!(result.is_ok());
203 }
204
205 #[test]
206 fn test_validate_opencode_agents_invalid_provider() {
207 let catalog = mock_catalog();
208 let fallback = create_fallback_with_refs(&["opencode/unknown/claude-sonnet-4-5"]);
209 let resolved = fallback.resolve_drains();
210
211 let result = validate_opencode_agents(&resolved, &catalog);
212 assert!(result.is_err());
213 assert!(result.unwrap_err().contains("unknown"));
214 }
215
216 #[test]
217 fn test_validate_opencode_agents_invalid_model() {
218 let catalog = mock_catalog();
219 let fallback = create_fallback_with_refs(&["opencode/anthropic/unknown-model"]);
220 let resolved = fallback.resolve_drains();
221
222 let result = validate_opencode_agents(&resolved, &catalog);
223 assert!(result.is_err());
224 assert!(result.unwrap_err().contains("unknown-model"));
225 }
226
227 #[test]
228 fn test_count_opencode_refs() {
229 let fallback = create_fallback_with_refs(&[
230 "opencode/anthropic/claude-sonnet-4-5",
231 "claude",
232 "opencode/openai/gpt-4",
233 ]);
234 let resolved = fallback.resolve_drains();
235
236 let count = count_opencode_refs(&resolved);
237 assert_eq!(count, 2);
238 }
239
240 #[test]
241 fn test_get_opencode_refs() {
242 let fallback = create_fallback_with_refs(&[
243 "opencode/anthropic/claude-sonnet-4-5",
244 "claude",
245 "opencode/openai/gpt-4",
246 ]);
247 let resolved = fallback.resolve_drains();
248
249 let refs = get_opencode_refs(&resolved);
250 assert_eq!(refs.len(), 2);
251 assert!(refs.contains(&"opencode/anthropic/claude-sonnet-4-5".to_string()));
252 assert!(refs.contains(&"opencode/openai/gpt-4".to_string()));
253 }
254}