use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use snafu::ResultExt;
use super::{ConfigFile, LaunchError, LaunchPlan, LaunchRecipe, ProxyEndpoint, launch_error};
const HARNESS_ID: &str = crate::harness::OPENCODE.id();
pub const OPENCODE_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME";
const CONFIG_RELATIVE_PATH: [&str; 2] = ["opencode", "opencode.json"];
const PLUGIN_RELATIVE_DIR: [&str; 2] = ["opencode", "plugins"];
const PROVIDER_KEY: &str = "provider";
const MODEL_SELECTION_KEY: &str = "model";
const PROVIDER_METAS: &[(&str, &str, &str)] = &[
("anthropic", "@ai-sdk/anthropic", "Anthropic"),
("openai", "@ai-sdk/openai", "OpenAI"),
("ollama", "@ai-sdk/openai-compatible", "Ollama"),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenCodeProvider {
pub name: String,
pub endpoint: ProxyEndpoint,
pub api_key: Option<String>,
}
impl OpenCodeProvider {
pub fn new(name: impl Into<String>, endpoint: ProxyEndpoint) -> Self {
Self {
name: name.into(),
endpoint,
api_key: None,
}
}
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenCodeRecipe {
config_root: PathBuf,
providers: Vec<OpenCodeProvider>,
model: Option<String>,
base_config: Option<Value>,
}
impl OpenCodeRecipe {
pub fn new(config_root: impl Into<PathBuf>, providers: Vec<OpenCodeProvider>) -> Self {
Self {
config_root: config_root.into(),
providers,
model: None,
base_config: None,
}
}
pub fn with_model(mut self, provider: &str, model: &str) -> Self {
self.model = Some(format!("{provider}/{model}"));
self
}
pub fn with_base_config(mut self, base: Value) -> Self {
self.base_config = Some(base);
self
}
pub fn config_path(&self) -> PathBuf {
CONFIG_RELATIVE_PATH
.iter()
.fold(self.config_root.clone(), |path, segment| path.join(segment))
}
pub fn plugin_path(&self) -> PathBuf {
PLUGIN_RELATIVE_DIR
.iter()
.fold(self.config_root.clone(), |path, segment| path.join(segment))
.join(crate::plugin::OPENCODE_GATEWAY_EXTENSION.file_name())
}
}
impl LaunchRecipe for OpenCodeRecipe {
fn harness(&self) -> &str {
HARNESS_ID
}
fn plan(&self) -> Result<LaunchPlan, LaunchError> {
let mut document = match self.base_config.clone() {
Some(Value::Object(map)) => map,
_ => Map::new(),
};
if let Some(providers) = ensure_object(&mut document, PROVIDER_KEY) {
for provider in &self.providers {
configure_provider(providers, provider);
}
providers.retain(|name, _| self.providers.iter().any(|p| p.name == *name));
}
if let Some(selected) = document.get(MODEL_SELECTION_KEY).and_then(Value::as_str)
&& let Some((provider_name, _)) = selected.split_once('/')
&& !self.providers.iter().any(|p| p.name == provider_name)
{
document.remove(MODEL_SELECTION_KEY);
}
let contents = serde_json::to_string_pretty(&Value::Object(document))
.context(launch_error::SerializeOpenCodeConfigSnafu)?;
let args = match &self.model {
Some(model) => vec!["--model".to_string(), model.clone()],
None => Vec::new(),
};
Ok(LaunchPlan {
args,
env: vec![(
OPENCODE_CONFIG_HOME_ENV.to_string(),
self.config_root.display().to_string(),
)],
config_files: vec![
ConfigFile {
path: self.config_path(),
contents,
},
ConfigFile {
path: self.plugin_path(),
contents: crate::plugin::OPENCODE_GATEWAY_EXTENSION
.contents()
.to_owned(),
},
],
})
}
}
fn configure_provider(providers: &mut Map<String, Value>, provider: &OpenCodeProvider) {
let Some(entry) = ensure_object(providers, &provider.name) else {
return;
};
if let Some((_, npm, display)) = PROVIDER_METAS
.iter()
.find(|(name, _, _)| *name == provider.name)
{
entry
.entry("npm".to_string())
.or_insert_with(|| Value::String((*npm).to_string()));
entry
.entry("name".to_string())
.or_insert_with(|| Value::String((*display).to_string()));
}
let Some(options) = ensure_object(entry, "options") else {
return;
};
options.insert(
"baseURL".to_string(),
Value::String(provider.endpoint.as_str().to_string()),
);
if let Some(api_key) = &provider.api_key {
options.insert("apiKey".to_string(), Value::String(api_key.clone()));
}
}
fn ensure_object<'a>(
target: &'a mut Map<String, Value>,
key: &str,
) -> Option<&'a mut Map<String, Value>> {
let entry = target
.entry(key.to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !entry.is_object() {
*entry = Value::Object(Map::new());
}
entry.as_object_mut()
}
pub fn opencode_user_config_candidates() -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(xdg) = std::env::var_os(OPENCODE_CONFIG_HOME_ENV)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
{
candidates.push(config_file_under(&xdg));
}
if let Some(home) = dirs::home_dir() {
candidates.push(config_file_under(&home.join(".config")));
}
candidates
}
fn config_file_under(root: &Path) -> PathBuf {
CONFIG_RELATIVE_PATH
.iter()
.fold(root.to_path_buf(), |path, segment| path.join(segment))
}
pub fn opencode_auth_file() -> Option<PathBuf> {
let data_home = std::env::var_os("XDG_DATA_HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|home| home.join(".local").join("share")))?;
Some(data_home.join("opencode").join("auth.json"))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn base_config_providers_that_were_not_routed_are_dropped() {
let base = serde_json::json!({
"theme": "dark",
"provider": {
"mystery": { "options": { "baseURL": "https://direct.example.com/v1" } }
}
});
let plan = recipe().with_base_config(base).plan().unwrap();
let document = parse(&plan.config_files[0].contents);
let providers = document["provider"].as_object().unwrap();
assert!(
!providers.contains_key("mystery"),
"unrouted provider retained: {providers:?}"
);
assert_eq!(document["theme"], "dark");
}
#[test]
fn model_selection_does_not_outlive_its_pruned_provider() {
let base = serde_json::json!({
"model": "mystery/secret-model",
"provider": {
"mystery": { "options": { "baseURL": "https://direct.example.com/v1" } }
}
});
let plan = recipe().with_base_config(base).plan().unwrap();
let document = parse(&plan.config_files[0].contents);
assert!(
document.get("model").is_none(),
"stale selection retained: {:?}",
document.get("model")
);
let base = serde_json::json!({ "model": "anthropic/claude-sonnet-4-6" });
let plan = recipe().with_base_config(base).plan().unwrap();
let document = parse(&plan.config_files[0].contents);
assert_eq!(document["model"], "anthropic/claude-sonnet-4-6");
}
fn parse(contents: &str) -> Value {
serde_json::from_str(contents).unwrap()
}
fn recipe() -> OpenCodeRecipe {
OpenCodeRecipe::new(
"/tmp/tapes-opencode-config-XXXX",
vec![
OpenCodeProvider::new(
"anthropic",
ProxyEndpoint::new("http://127.0.0.1:9/agents/opencode/providers/anthropic/v1"),
)
.with_api_key("sk-ant-not-real"),
OpenCodeProvider::new(
"openai",
ProxyEndpoint::new("http://127.0.0.1:9/agents/opencode/providers/openai"),
)
.with_api_key("sk-not-real"),
OpenCodeProvider::new(
"ollama",
ProxyEndpoint::new("http://127.0.0.1:9/agents/opencode/providers/ollama/v1"),
),
],
)
}
#[test]
fn plan_writes_every_provider_entry() {
let plan = recipe().plan().unwrap();
let document = parse(&plan.config_files[0].contents);
let providers = &document["provider"];
assert_eq!(providers["anthropic"]["npm"], "@ai-sdk/anthropic");
assert_eq!(providers["anthropic"]["name"], "Anthropic");
assert_eq!(
providers["anthropic"]["options"]["baseURL"],
"http://127.0.0.1:9/agents/opencode/providers/anthropic/v1",
);
assert_eq!(
providers["anthropic"]["options"]["apiKey"],
"sk-ant-not-real"
);
assert_eq!(providers["openai"]["npm"], "@ai-sdk/openai");
assert_eq!(providers["openai"]["name"], "OpenAI");
assert_eq!(
providers["openai"]["options"]["baseURL"],
"http://127.0.0.1:9/agents/opencode/providers/openai",
);
assert_eq!(providers["ollama"]["npm"], "@ai-sdk/openai-compatible");
assert_eq!(providers["ollama"]["name"], "Ollama");
assert!(
providers["ollama"]["options"].get("apiKey").is_none(),
"no key supplied → no apiKey field: {}",
providers["ollama"]["options"],
);
}
#[test]
fn plan_places_the_config_where_opencode_reads_it() {
let plan = recipe().plan().unwrap();
assert_eq!(
plan.config_files[0].path,
PathBuf::from("/tmp/tapes-opencode-config-XXXX/opencode/opencode.json"),
);
assert_eq!(
plan.env,
vec![(
"XDG_CONFIG_HOME".to_string(),
"/tmp/tapes-opencode-config-XXXX".to_string(),
)],
);
}
#[test]
fn plan_carries_the_capture_plugin_into_the_relocated_config_root() {
let plan = recipe().plan().unwrap();
let plugin = plan
.config_files
.iter()
.find(|file| file.path == recipe().plugin_path())
.expect("the plan does not carry the capture plugin");
assert_eq!(
plugin.path,
PathBuf::from("/tmp/tapes-opencode-config-XXXX/opencode/plugins/tapes-gateway.ts"),
);
assert_eq!(
plugin.contents,
crate::plugin::OPENCODE_GATEWAY_EXTENSION.contents(),
);
}
#[test]
fn the_plans_plugin_directory_is_the_artifacts_own_directory() {
let installed = crate::plugin::OPENCODE_GATEWAY_EXTENSION.install_dir_components();
let expected: Vec<&str> = std::iter::once(".config")
.chain(PLUGIN_RELATIVE_DIR.iter().copied())
.collect();
assert_eq!(
installed, expected,
"the artifact installs to {installed:?} but a plan writes it to \
<config-root>/{PLUGIN_RELATIVE_DIR:?}",
);
}
#[test]
fn every_file_a_plan_writes_lives_under_the_root_the_consumer_owns() {
let recipe = recipe();
let plan = recipe.plan().unwrap();
assert_eq!(plan.config_files.len(), 2);
for file in &plan.config_files {
assert!(
file.path.starts_with("/tmp/tapes-opencode-config-XXXX"),
"{:?} escapes the config root the consumer created and deletes",
file.path,
);
}
}
#[test]
fn plan_preserves_user_settings_but_overwrites_the_base_url() {
let base = serde_json::json!({
"theme": "gruvbox",
"provider": {
"anthropic": {
"npm": "@scoped/custom-adapter",
"name": "My Anthropic",
"options": {
"baseURL": "https://api.anthropic.com",
"timeout": 30
}
},
"unrelated": { "options": { "baseURL": "https://elsewhere" } }
}
});
let plan = OpenCodeRecipe::new(
"/tmp/root",
vec![OpenCodeProvider::new(
"anthropic",
ProxyEndpoint::new("http://127.0.0.1:9/providers/anthropic/v1"),
)],
)
.with_base_config(base)
.plan()
.unwrap();
let document = parse(&plan.config_files[0].contents);
assert_eq!(document["theme"], "gruvbox", "unrelated keys survive");
let anthropic = &document["provider"]["anthropic"];
assert_eq!(
anthropic["npm"], "@scoped/custom-adapter",
"the user's adapter choice wins",
);
assert_eq!(anthropic["name"], "My Anthropic");
assert_eq!(
anthropic["options"]["timeout"], 30,
"sibling options survive"
);
assert_eq!(
anthropic["options"]["baseURL"], "http://127.0.0.1:9/providers/anthropic/v1",
"the redirect always wins",
);
assert!(
document["provider"].get("unrelated").is_none(),
"providers this recipe did not name must not survive: they are \
selectable routes the capture proxy never sees",
);
}
#[test]
fn plan_ignores_a_non_object_base_config() {
let plan = OpenCodeRecipe::new(
"/tmp/root",
vec![OpenCodeProvider::new(
"openai",
ProxyEndpoint::new("http://127.0.0.1:9"),
)],
)
.with_base_config(serde_json::json!(["not", "an", "object"]))
.plan()
.unwrap();
let document = parse(&plan.config_files[0].contents);
assert!(document["provider"]["openai"]["options"]["baseURL"].is_string());
}
#[test]
fn plan_replaces_a_non_object_provider_table() {
let plan = OpenCodeRecipe::new(
"/tmp/root",
vec![OpenCodeProvider::new(
"openai",
ProxyEndpoint::new("http://127.0.0.1:9"),
)],
)
.with_base_config(serde_json::json!({"provider": "nonsense"}))
.plan()
.unwrap();
let document = parse(&plan.config_files[0].contents);
assert_eq!(
document["provider"]["openai"]["options"]["baseURL"],
"http://127.0.0.1:9",
);
}
#[test]
fn plan_configures_an_unknown_provider_without_defaults() {
let plan = OpenCodeRecipe::new(
"/tmp/root",
vec![OpenCodeProvider::new(
"my-gateway",
ProxyEndpoint::new("http://127.0.0.1:9/v1"),
)],
)
.plan()
.unwrap();
let entry = &parse(&plan.config_files[0].contents)["provider"]["my-gateway"];
assert_eq!(entry["options"]["baseURL"], "http://127.0.0.1:9/v1");
assert!(entry.get("npm").is_none(), "no adapter guess: {entry}");
assert!(entry.get("name").is_none());
}
#[test]
fn plan_pins_the_model_only_when_requested() {
assert!(recipe().plan().unwrap().args.is_empty());
let plan = recipe()
.with_model("anthropic", "claude-sonnet-4-5")
.plan()
.unwrap();
assert_eq!(
plan.args,
vec![
"--model".to_string(),
"anthropic/claude-sonnet-4-5".to_string(),
],
);
}
#[test]
fn plan_is_byte_stable() {
let first = recipe().plan().unwrap();
let second = recipe().plan().unwrap();
assert_eq!(
first.config_files[0].contents,
second.config_files[0].contents
);
assert!(
first.config_files[0].contents.contains("\n \""),
"two-space indented, matching Go's MarshalIndent: {}",
first.config_files[0].contents,
);
}
#[test]
fn user_config_candidates_end_at_opencode_json() {
for candidate in opencode_user_config_candidates() {
assert!(
candidate.ends_with("opencode/opencode.json"),
"{candidate:?}"
);
}
}
#[test]
fn auth_file_names_the_conventional_path() {
if let Some(path) = opencode_auth_file() {
assert!(path.ends_with("opencode/auth.json"), "{path:?}");
}
}
#[test]
fn harness_id_is_opencode() {
assert_eq!(recipe().harness(), "opencode");
}
}