use crate::agents::config::AgentConfig;
use crate::agents::opencode_api::ApiCatalog;
use crate::agents::parser::JsonParserType;
use itertools::Itertools;
use strsim::levenshtein;
const MAX_TYPO_DISTANCE: usize = 3;
#[derive(Debug)]
pub struct OpenCodeResolver {
catalog: ApiCatalog,
}
impl OpenCodeResolver {
pub const fn new(catalog: ApiCatalog) -> Self {
Self { catalog }
}
pub fn try_resolve(&self, name: &str) -> Option<AgentConfig> {
if name == "opencode" {
return Some(Self::build_default_config());
}
if !name.starts_with("opencode/") {
return None;
}
let parts: Vec<&str> = name.split('/').collect();
if parts.len() != 3 {
return None;
}
let provider = parts.get(1)?;
let model = parts.get(2)?;
if !self.catalog.has_provider(provider) {
return None;
}
if !self.catalog.has_model(provider, model) {
return None;
}
Some(Self::build_config(provider, model))
}
fn build_config(provider: &str, model: &str) -> AgentConfig {
let model_flag = format!("-m {provider}/{model}");
let env_vars = std::collections::HashMap::from([(
"OPENCODE_PERMISSION".to_string(),
r#"{"*": "allow"}"#.to_string(),
)]);
AgentConfig {
cmd: "opencode run".to_string(),
output_flag: "--format json".to_string(),
yolo_flag: String::new(),
verbose_flag: "--log-level DEBUG --print-logs".to_string(),
can_commit: true,
json_parser: JsonParserType::OpenCode,
model_flag: Some(model_flag),
print_flag: String::new(),
streaming_flag: String::new(),
session_flag: "-s {}".to_string(),
env_vars,
display_name: Some(format!("OpenCode ({provider})")),
}
}
fn build_default_config() -> AgentConfig {
let env_vars = std::collections::HashMap::from([(
"OPENCODE_PERMISSION".to_string(),
r#"{"*": "allow"}"#.to_string(),
)]);
AgentConfig {
cmd: "opencode run".to_string(),
output_flag: "--format json".to_string(),
yolo_flag: String::new(),
verbose_flag: "--log-level DEBUG --print-logs".to_string(),
can_commit: true,
json_parser: JsonParserType::OpenCode,
model_flag: None,
print_flag: String::new(),
streaming_flag: String::new(),
session_flag: "-s {}".to_string(),
env_vars,
display_name: Some("OpenCode".to_string()),
}
}
pub fn validate(&self, provider: &str, model: &str) -> Result<(), ValidationError> {
if !self.catalog.has_provider(provider) {
return Err(ValidationError::ProviderNotFound {
provider: provider.to_string(),
suggestions: self.suggest_providers(provider),
});
}
if !self.catalog.has_model(provider, model) {
return Err(ValidationError::ModelNotFound {
provider: provider.to_string(),
model: model.to_string(),
suggestions: self.suggest_models(provider, model),
});
}
Ok(())
}
fn suggest_providers(&self, provider: &str) -> Vec<String> {
self.catalog
.provider_names()
.into_iter()
.map(|p| {
let distance = levenshtein(provider, &p);
(p, distance)
})
.filter(|(_, d)| *d <= MAX_TYPO_DISTANCE)
.sorted_by_key(|(_, d)| *d)
.take(MAX_TYPO_DISTANCE)
.map(|(p, _)| p)
.collect()
}
fn suggest_models(&self, provider: &str, model: &str) -> Vec<String> {
self.catalog
.get_model_ids(provider)
.into_iter()
.map(|m| {
let distance = levenshtein(model, &m);
(m, distance)
})
.filter(|(_, d)| *d <= MAX_TYPO_DISTANCE)
.sorted_by_key(|(_, d)| *d)
.take(MAX_TYPO_DISTANCE)
.map(|(m, _)| m)
.collect()
}
pub fn format_error(&self, error: &ValidationError, agent_name: &str) -> String {
match error {
ValidationError::ProviderNotFound {
provider,
suggestions,
} => {
let msg =
format!("Error: OpenCode provider '{provider}' not found in API catalog.\n");
let msg = if let Some(closest) = suggestions.first() {
format!("{msg}Did you mean: {closest}?\n")
} else {
msg
};
let msg = format!("{msg}Agent reference: {agent_name}");
let available = self.catalog.provider_names().join(", ");
format!(
"{msg}\nAvailable providers: {available}\n\nPlease update your agent configuration."
)
}
ValidationError::ModelNotFound {
provider,
model,
suggestions,
} => {
let msg = format!(
"Error: OpenCode model '{provider}/{model}' not found in API catalog.\n"
);
let msg = if let Some(closest) = suggestions.first() {
format!("{msg}Did you mean: {provider}/{closest}?\n")
} else {
msg
};
let msg = format!("{msg}Agent reference: {agent_name}\n");
let available = self.catalog.get_model_ids(provider).join(", ");
format!(
"{msg}Available models for '{provider}': {available}\n\nPlease update your agent configuration."
)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
ProviderNotFound {
provider: String,
suggestions: Vec<String>,
},
ModelNotFound {
provider: String,
model: String,
suggestions: Vec<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::opencode_api::{Model, Provider};
use std::collections::HashMap;
fn mock_api_catalog() -> ApiCatalog {
let providers = HashMap::from([
(
"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([
(
"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: 86400,
}
}
#[test]
fn test_try_resolve_valid_pattern() {
let catalog = mock_api_catalog();
let resolver = OpenCodeResolver::new(catalog);
let config = resolver.try_resolve("opencode/anthropic/claude-sonnet-4-5");
assert!(config.is_some());
let config = config.unwrap();
assert_eq!(config.cmd, "opencode run");
assert_eq!(
config.model_flag,
Some("-m anthropic/claude-sonnet-4-5".to_string())
);
assert_eq!(config.json_parser, JsonParserType::OpenCode);
}
#[test]
fn test_try_resolve_plain_opencode() {
let catalog = mock_api_catalog();
let resolver = OpenCodeResolver::new(catalog);
let config = resolver.try_resolve("opencode");
assert!(config.is_some());
let config = config.unwrap();
assert_eq!(config.cmd, "opencode run");
assert_eq!(config.model_flag, None); assert_eq!(config.json_parser, JsonParserType::OpenCode);
assert_eq!(
config.env_vars.get("OPENCODE_PERMISSION"),
Some(&r#"{"*": "allow"}"#.to_string())
);
assert_eq!(config.display_name, Some("OpenCode".to_string()));
}
#[test]
fn test_try_resolve_invalid_pattern() {
let catalog = mock_api_catalog();
let resolver = OpenCodeResolver::new(catalog);
assert!(resolver.try_resolve("claude").is_none());
assert!(resolver.try_resolve("ccs/glm").is_none());
assert!(resolver.try_resolve("opencode/anthropic").is_none());
assert!(resolver.try_resolve("opencode/unknown/model").is_none());
assert!(resolver
.try_resolve("opencode/anthropic/unknown-model")
.is_none());
}
#[test]
fn test_validate_valid_provider_model() {
let catalog = mock_api_catalog();
let resolver = OpenCodeResolver::new(catalog);
assert!(resolver.validate("anthropic", "claude-sonnet-4-5").is_ok());
assert!(resolver.validate("openai", "gpt-4").is_ok());
}
#[test]
fn test_validate_invalid_provider() {
let catalog = mock_api_catalog();
let resolver = OpenCodeResolver::new(catalog);
let result = resolver.validate("unknown", "model");
assert!(result.is_err());
if let Err(ValidationError::ProviderNotFound { provider, .. }) = result {
assert_eq!(provider, "unknown");
} else {
panic!("Expected ProviderNotFound error");
}
}
#[test]
fn test_validate_invalid_model() {
let catalog = mock_api_catalog();
let resolver = OpenCodeResolver::new(catalog);
let result = resolver.validate("anthropic", "unknown-model");
assert!(result.is_err());
if let Err(ValidationError::ModelNotFound { model, .. }) = result {
assert_eq!(model, "unknown-model");
} else {
panic!("Expected ModelNotFound error");
}
}
#[test]
fn test_build_config() {
let catalog = mock_api_catalog();
let _resolver = OpenCodeResolver::new(catalog);
let config = OpenCodeResolver::build_config("anthropic", "claude-sonnet-4-5");
assert_eq!(config.cmd, "opencode run");
assert_eq!(
config.model_flag,
Some("-m anthropic/claude-sonnet-4-5".to_string())
);
assert_eq!(config.output_flag, "--format json");
assert_eq!(config.yolo_flag, "");
assert_eq!(config.json_parser, JsonParserType::OpenCode);
assert!(config.can_commit);
assert_eq!(
config.env_vars.get("OPENCODE_PERMISSION"),
Some(&r#"{"*": "allow"}"#.to_string())
);
}
#[test]
fn test_format_error_provider_not_found() {
let catalog = mock_api_catalog();
let resolver = OpenCodeResolver::new(catalog);
let error = ValidationError::ProviderNotFound {
provider: "antrhopic".to_string(),
suggestions: vec!["anthropic".to_string()],
};
let msg = resolver.format_error(&error, "opencode/antrhopic/claude-sonnet-4-5");
assert!(msg.contains("antrhopic"));
assert!(msg.contains("anthropic"));
assert!(msg.contains("opencode/antrhopic/claude-sonnet-4-5"));
assert!(msg.contains("Available providers"));
}
#[test]
fn test_format_error_model_not_found() {
let catalog = mock_api_catalog();
let resolver = OpenCodeResolver::new(catalog);
let error = ValidationError::ModelNotFound {
provider: "anthropic".to_string(),
model: "claude-sonnet-4".to_string(),
suggestions: vec!["claude-sonnet-4-5".to_string()],
};
let msg = resolver.format_error(&error, "opencode/anthropic/claude-sonnet-4");
assert!(msg.contains("anthropic/claude-sonnet-4"));
assert!(msg.contains("claude-sonnet-4-5"));
assert!(msg.contains("Available models"));
}
}