use crate::agents::adapter::{AgentAdapter, Backup};
use crate::config::ModelConfig;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct OpenCodeConfig {
#[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
schema: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<HashMap<String, OpenCodeProvider>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpenCodeProvider {
#[serde(skip_serializing_if = "Option::is_none")]
npm: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
options: Option<OpenCodeProviderOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
models: Option<HashMap<String, OpenCodeModel>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpenCodeProviderOptions {
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpenCodeModel {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
limit: Option<OpenCodeModelLimit>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpenCodeModelLimit {
#[serde(skip_serializing_if = "Option::is_none")]
context: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct OpenCodeAuth {
#[serde(flatten)]
providers: HashMap<String, OpenCodeAuthProvider>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OpenCodeAuthProvider {
#[serde(skip_serializing_if = "Option::is_none")]
api_key: Option<String>,
}
pub struct OpenCodeAdapter {
provider_name: String,
}
impl OpenCodeAdapter {
pub fn new() -> Self {
Self {
provider_name: "custom".to_string(),
}
}
pub fn with_provider_name(provider_name: &str) -> Self {
Self {
provider_name: provider_name.to_string(),
}
}
fn config_dir(&self) -> Result<PathBuf> {
Ok(dirs::home_dir()
.context("无法找到用户主目录")?
.join(".config")
.join("opencode"))
}
fn auth_dir(&self) -> Result<PathBuf> {
Ok(dirs::home_dir()
.context("无法找到用户主目录")?
.join(".local")
.join("share")
.join("opencode"))
}
fn auth_path(&self) -> Result<PathBuf> {
Ok(self.auth_dir()?.join("auth.json"))
}
}
impl AgentAdapter for OpenCodeAdapter {
fn name(&self) -> &str {
"opencode"
}
fn detect(&self) -> Result<bool> {
let in_path = which::which("opencode").is_ok();
let config_path = self.config_path();
let has_global_config = config_path.is_ok() && config_path.unwrap().exists();
let has_project_config = std::env::current_dir()
.map(|cwd| cwd.join("opencode.json").exists())
.unwrap_or(false);
Ok(in_path || has_global_config || has_project_config)
}
fn config_path(&self) -> Result<PathBuf> {
Ok(self.config_dir()?.join("opencode.json"))
}
fn backup(&self) -> Result<Backup> {
let config_path = self.config_path()?;
let backup_dir = dirs::home_dir()
.context("无法找到用户主目录")?
.join(".agentswitch")
.join("backups")
.join("opencode");
std::fs::create_dir_all(&backup_dir).context("创建备份目录失败")?;
let timestamp = chrono::Utc::now();
let backup_filename = format!("backup-{}.json", timestamp.format("%Y%m%d-%H%M%S"));
let backup_path = backup_dir.join(&backup_filename);
if config_path.exists() {
std::fs::copy(&config_path, &backup_path).context("备份配置文件失败")?;
} else {
std::fs::write(&backup_path, "{}").context("创建空备份失败")?;
}
Ok(Backup {
agent_name: self.name().to_string(),
original_config_path: config_path,
backup_path,
timestamp,
})
}
fn apply(&self, model_config: &ModelConfig) -> Result<()> {
let config_dir = self.config_dir()?;
fs::create_dir_all(&config_dir).context("创建配置目录失败")?;
let config_path = config_dir.join("opencode.json");
let mut config = if config_path.exists() {
let content = fs::read_to_string(&config_path).context("读取 opencode.json 失败")?;
serde_json::from_str::<OpenCodeConfig>(&content).context("解析 opencode.json 失败")?
} else {
OpenCodeConfig {
schema: Some("https://opencode.ai/config.json".to_string()),
..Default::default()
}
};
let model_name = OpenCodeModel {
name: Some(model_config.name.clone()),
limit: Some(OpenCodeModelLimit {
context: Some(128000),
output: Some(16384),
}),
};
let provider_options = OpenCodeProviderOptions {
base_url: Some(model_config.base_url.clone()),
api_key: Some(format!(
"{{env:OPENCODE_{}_API_KEY}}",
self.provider_name.to_uppercase()
)),
headers: None,
};
let mut models = HashMap::new();
models.insert(model_config.model_id.clone(), model_name);
let provider = OpenCodeProvider {
npm: Some("@ai-sdk/openai-compatible".to_string()),
name: Some(format!("{} (Custom)", model_config.name)),
options: Some(provider_options),
models: Some(models),
};
let providers = config.provider.get_or_insert_with(HashMap::new);
providers.insert(self.provider_name.clone(), provider);
config.model = Some(format!("{}/{}", self.provider_name, model_config.model_id));
let content = serde_json::to_string_pretty(&config).context("序列化 opencode.json 失败")?;
fs::write(&config_path, content).context("写入 opencode.json 失败")?;
let auth_dir = self.auth_dir()?;
fs::create_dir_all(&auth_dir).context("创建认证目录失败")?;
let auth_path = self.auth_path()?;
let mut auth = if auth_path.exists() {
let content = fs::read_to_string(&auth_path).context("读取 auth.json 失败")?;
serde_json::from_str::<OpenCodeAuth>(&content).context("解析 auth.json 失败")?
} else {
OpenCodeAuth::default()
};
auth.providers.insert(
self.provider_name.clone(),
OpenCodeAuthProvider {
api_key: Some(model_config.api_key.clone()),
},
);
let content = serde_json::to_string_pretty(&auth).context("序列化 auth.json 失败")?;
fs::write(&auth_path, content).context("写入 auth.json 失败")?;
Ok(())
}
fn restore(&self, backup: &Backup) -> Result<()> {
if backup.backup_path.exists() {
let backup_content =
fs::read_to_string(&backup.backup_path).context("读取备份文件失败")?;
if backup_content.trim() == "{}" {
if backup.original_config_path.exists() {
std::fs::remove_file(&backup.original_config_path)
.context("删除配置文件失败")?;
}
} else {
std::fs::copy(&backup.backup_path, &backup.original_config_path)
.context("恢复备份失败")?;
}
}
Ok(())
}
fn current_model(&self) -> Result<Option<String>> {
let config_path = self.config_path()?;
if !config_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&config_path).context("读取配置文件失败")?;
let config: OpenCodeConfig = serde_json::from_str(&content).context("解析配置文件失败")?;
Ok(config.model)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_opencode_adapter_creation() {
let adapter = OpenCodeAdapter::new();
assert_eq!(adapter.name(), "opencode");
}
#[test]
fn test_opencode_adapter_with_provider_name() {
let adapter = OpenCodeAdapter::with_provider_name("zhipu");
assert_eq!(adapter.name(), "opencode");
assert_eq!(adapter.provider_name, "zhipu");
}
#[test]
fn test_config_structure() {
let config = OpenCodeConfig {
schema: Some("https://opencode.ai/config.json".to_string()),
model: Some("custom/gpt-4".to_string()),
provider: None,
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("\"$schema\""));
assert!(json.contains("\"model\""));
}
}