From 948a1408d79fbdd90098cf6ff5752742732f8142 Mon Sep 17 00:00:00 2001 From: alderpath Date: Tue, 16 Jun 2026 22:05:22 +0100 Subject: [PATCH] feat: proxy auto-discovery for OpenCode + init proxy-route injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit routes.rs: - Added scan_opencode_configs() — discovers upstream by scanning opencode.json providers - Added scan_claude_config() — discovers upstream from ~/.claude.json - Added scan_cline_config() — discovers upstream from Cline config - Added normalize_url() — ensures proper /chat/completions or /v1/messages suffix - Auto-discovery cascade: proxy-routes.json > OpenCode > Claude > Cline > Pi > env vars init.rs: - inject_opencode_proxy_routes() — reads opencode.json, mutates provider baseURLs to http://127.0.0.1:9090/v1, generates proxy-routes.json - restore_opencode_proxy_routes() — restores original baseURLs on uninstall - Prompt asks user Y/N before mutating config --- crates/reliary-agent/src/init.rs | 179 ++++++++++++++++++++++++++++- crates/reliary-agent/src/routes.rs | 133 +++++++++++++++++++-- 2 files changed, 299 insertions(+), 13 deletions(-) diff --git a/crates/reliary-agent/src/init.rs b/crates/reliary-agent/src/init.rs index 91c8628..644c3dc 100644 --- a/crates/reliary-agent/src/init.rs +++ b/crates/reliary-agent/src/init.rs @@ -1,6 +1,7 @@ use std::io::{self, Write}; use std::path::PathBuf; use std::fs; +use std::collections::HashMap; use std::process::Command; use serde_json::Value; @@ -123,7 +124,7 @@ pub fn run() { Some(home.join(".config/opencode/opencode.json")) }; - if let Some(cfg_path) = opencode_cfg { + if let Some(cfg_path) = opencode_cfg { if cfg_path.exists() { if ask_yes_no("Found OpenCode config. Add Reliary MCP server?", true) { if inject_mcp_server(&cfg_path, "reliary") { @@ -135,6 +136,21 @@ pub fn run() { } else { println!(" {} Skipped\n", "\x1b[33m-\x1b[0m"); } + + // Proxy routing — after MCP injection, ask to mutate provider baseURLs + if ask_yes_no("\nRoute all OpenCode providers through Reliary proxy?\n(Your provider baseURLs will be updated to point at localhost:9090\nand proxy-routes.json will be generated automatically)", true) { + let (count, routes) = inject_opencode_proxy_routes(&cfg_path); + if count > 0 { + ok(&format!("Updated {} OpenCode provider baseURLs", count)); + if write_proxy_routes(&routes) { + ok("Generated ~/.reliary/proxy-routes.json"); + } + } else { + println!(" {} No providers found to update\n", "\x1b[33m-\x1b[0m"); + } + } else { + println!(" {} Skipped\n", "\x1b[33m-\x1b[0m"); + } } } } @@ -360,9 +376,15 @@ pub fn uninstall() { }; if let Some(cfg_path) = opencode_cfg { - if cfg_path.exists() && remove_mcp_server(&cfg_path, "reliary") { - ok("Removed Reliary from OpenCode"); - removed_agents += 1; + if cfg_path.exists() { + if remove_mcp_server(&cfg_path, "reliary") { + ok("Removed Reliary from OpenCode"); + removed_agents += 1; + } + // Restore original provider baseURLs + if restore_opencode_proxy_routes() { + ok("Restored OpenCode provider baseURLs"); + } } } } @@ -485,3 +507,152 @@ fn uninstall_daemon() -> bool { removed } + +/// Inject proxy baseURLs for all OpenCode providers. +/// Returns (count_of_updated_providers, routes_map). +fn inject_opencode_proxy_routes(cfg_path: &PathBuf) -> (usize, HashMap) { + let content = match std::fs::read_to_string(cfg_path) { + Ok(c) => c, + Err(_) => return (0, HashMap::new()), + }; + let mut v: Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return (0, HashMap::new()), + }; + + let mut routes: HashMap = HashMap::new(); + let mut backups: HashMap = HashMap::new(); + let mut count = 0; + + if let Some(obj) = v.as_object_mut() { + if let Some(providers) = obj.get_mut("provider").and_then(|p| p.as_object_mut()) { + for (name, provider) in providers.iter_mut() { + if let Some(options) = provider.get_mut("options").and_then(|o| o.as_object_mut()) { + let original_url = match options.get("baseURL").and_then(|v| v.as_str()) { + Some(u) => u.to_string(), + None => continue, + }; + // Skip if already pointing at proxy + if original_url.contains("127.0.0.1:9090") || original_url.contains("localhost:9090") { + continue; + } + let api_key = match options.get("apiKey").and_then(|v| v.as_str()) { + Some(k) => k.to_string(), + None => continue, + }; + // Update baseURL to proxy + options.insert("baseURL".to_string(), Value::String("http://127.0.0.1:9090/v1".to_string())); + routes.insert(api_key, original_url.clone()); + backups.insert(name.clone(), original_url); + count += 1; + } + } + } + } + + // Write updated opencode.json + if count > 0 { + // Add backup metadata to the routes map + let mut routes_with_backups = routes.clone(); + if !backups.is_empty() { + routes_with_backups.insert("__backups".to_string(), serde_json::to_string(&backups).unwrap_or_default()); + } + + if let Ok(new_content) = serde_json::to_string_pretty(&v) { + atomic_write(&cfg_path.to_string_lossy(), &new_content); + } + (count, routes) + } else { + (0, routes) + } +} + +/// Write proxy-routes.json to ~/.reliary/ +fn write_proxy_routes(routes: &HashMap) -> bool { + let home = match dirs::home_dir() { + Some(h) => h, + None => return false, + }; + let config_dir = home.join(".reliary"); + let _ = std::fs::create_dir_all(&config_dir); + + // Build routes JSON from api_key -> upstream_url + let mut routes_json = serde_json::Map::new(); + for (key, value) in routes { + routes_json.insert(key.clone(), Value::String(value.clone())); + } + + let routes_path = config_dir.join("proxy-routes.json"); + let content = serde_json::to_string_pretty(&serde_json::Value::Object(routes_json)) + .unwrap_or_default(); + atomic_write(&routes_path.to_string_lossy(), &content) +} + +/// Restore original OpenCode provider baseURLs from proxy-routes.json backup. +pub fn restore_opencode_proxy_routes() -> bool { + let home = match dirs::home_dir() { + Some(h) => h, + None => return false, + }; + let cfg_path = if cfg!(target_os = "windows") { + std::env::var("APPDATA").ok() + .map(|d| PathBuf::from(d).join("opencode").join("opencode.json")) + } else if cfg!(target_os = "macos") { + Some(home.join("Library/Application Support/opencode/opencode.json")) + } else { + Some(home.join(".config/opencode/opencode.json")) + }; + let cfg_path = match cfg_path { + Some(p) => p, + None => return false, + }; + if !cfg_path.exists() { return false; } + + let routes_path = home.join(".reliary/proxy-routes.json"); + let routes_content = match std::fs::read_to_string(&routes_path) { + Ok(c) => c, + Err(_) => return false, + }; + let routes: Value = match serde_json::from_str(&routes_content) { + Ok(v) => v, + Err(_) => return false, + }; + let backups = match routes.get("__backups").and_then(|v| v.as_str()) { + Some(s) => s, + None => return false, + }; + let backups: HashMap = match serde_json::from_str(backups) { + Ok(m) => m, + Err(_) => return false, + }; + + let content = match std::fs::read_to_string(&cfg_path) { + Ok(c) => c, + Err(_) => return false, + }; + let mut v: Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return false, + }; + + let mut restored = 0; + if let Some(obj) = v.as_object_mut() { + if let Some(providers) = obj.get_mut("provider").and_then(|p| p.as_object_mut()) { + for (name, provider) in providers.iter_mut() { + if let Some(original_url) = backups.get(name) { + if let Some(options) = provider.get_mut("options").and_then(|o| o.as_object_mut()) { + options.insert("baseURL".to_string(), Value::String(original_url.clone())); + restored += 1; + } + } + } + } + } + + if restored > 0 { + if let Ok(new_content) = serde_json::to_string_pretty(&v) { + atomic_write(&cfg_path.to_string_lossy(), &new_content); + } + } + restored > 0 +} diff --git a/crates/reliary-agent/src/routes.rs b/crates/reliary-agent/src/routes.rs index c83824b..06270de 100644 --- a/crates/reliary-agent/src/routes.rs +++ b/crates/reliary-agent/src/routes.rs @@ -6,11 +6,23 @@ pub fn discover_upstream(auth_key: &str) -> Option { if let Some(url) = scan_proxy_routes(auth_key) { return Some(url); } - // 2. Pi provider configs + // 2. OpenCode provider configs + if let Some(url) = scan_opencode_configs(auth_key) { + return Some(url); + } + // 3. Claude Code config + if let Some(url) = scan_claude_config(auth_key) { + return Some(url); + } + // 4. Cline config + if let Some(url) = scan_cline_config(auth_key) { + return Some(url); + } + // 5. Pi provider configs if let Some(url) = scan_pi_configs(auth_key) { return Some(url); } - // 3. Environment variables (generic fallback) + // 6. Environment variables (generic fallback) if let Some(url) = scan_env_vars(auth_key) { return Some(url); } @@ -26,6 +38,88 @@ fn scan_proxy_routes(auth_key: &str) -> Option { routes.get(auth_key).cloned() } +/// Scan OpenCode's opencode.json for provider API keys matching the auth key. +fn scan_opencode_configs(auth_key: &str) -> Option { + let cfg_path = opencode_config_path()?; + let content = std::fs::read_to_string(cfg_path).ok()?; + let config: serde_json::Value = serde_json::from_str(&content).ok()?; + let providers = config.get("provider")?.as_object()?; + for (_name, provider) in providers { + let options = provider.get("options")?.as_object()?; + let api_key = options.get("apiKey")?.as_str()?; + if api_key == auth_key { + let base_url = options.get("baseURL")?.as_str()?; + // Normalize: append /chat/completions if missing + let url = normalize_url(base_url); + return Some(url); + } + } + None +} + +/// Scan Claude Code's ~/.claude.json for provider API keys. +fn scan_claude_config(auth_key: &str) -> Option { + let home = home_dir(); + let claude_path = home.join(".claude.json"); + let content = std::fs::read_to_string(claude_path).ok()?; + // Claude Code can have api keys under providers or directly + // Also check top-level project configs + let config: serde_json::Value = serde_json::from_str(&content).ok()?; + // Check for Anthropic API key + if let Some(api_key) = config.get("apiKey").and_then(|v| v.as_str()) { + if api_key == auth_key { + return Some("https://api.anthropic.com/v1/messages".to_string()); + } + } + // Check for custom keys under providers + if let Some(providers) = config.get("providers").and_then(|v| v.as_object()) { + for (_name, provider) in providers { + if let Some(api_key) = provider.get("apiKey").and_then(|v| v.as_str()) { + if api_key == auth_key { + if let Some(base_url) = provider.get("baseUrl").and_then(|v| v.as_str()) { + return Some(normalize_url(base_url)); + } + } + } + } + } + None +} + +/// Scan Cline's cline_mcp_settings.json for provider API keys. +fn scan_cline_config(auth_key: &str) -> Option { + let home = home_dir(); + let cline_paths = vec![ + if cfg!(target_os = "macos") { + home.join("Library/Application Support/Code/User/globalStorage/rooveterinery.cline/cline_mcp_settings.json") + } else { + home.join(".config/Code/User/globalStorage/rooveterinery.cline/cline_mcp_settings.json") + }, + home.join(".config/Code/User/globalStorage/rooveterinary.cline/cline_mcp_settings.json"), + home.join("AppData/Roaming/Code/User/globalStorage/rooveterinery.cline/cline_mcp_settings.json"), + ]; + for path in &cline_paths { + if !path.exists() { continue; } + if let Some(url) = scan_single_cline_config(path, auth_key) { + return Some(url); + } + } + None +} + +fn scan_single_cline_config(path: &PathBuf, auth_key: &str) -> Option { + let content = std::fs::read_to_string(path).ok()?; + let config: serde_json::Value = serde_json::from_str(&content).ok()?; + // Cline stores API keys inline in MCP server args, not in a providers structure. + // Check for anthropic key + if let Some(api_key) = config.get("apiKey").and_then(|v| v.as_str()) { + if api_key == auth_key { + return Some("https://api.anthropic.com/v1/messages".to_string()); + } + } + None +} + /// Scan Pi's ~/.pi/agent/models.json for provider API keys matching the auth key. fn scan_pi_configs(auth_key: &str) -> Option { let pi_config = home_dir().join(".pi/agent/models.json"); @@ -39,12 +133,7 @@ fn scan_pi_configs(auth_key: &str) -> Option { let resolved = resolve_env_var(api_field); if resolved == auth_key { if let Some(base_url) = provider.get("baseUrl").and_then(|v| v.as_str()) { - // Append chat completions path if needed - let url = if base_url.ends_with("/chat/completions") || base_url.ends_with("/v1/messages") { - base_url.to_string() - } else { - format!("{}/chat/completions", base_url.trim_end_matches('/')) - }; + let url = normalize_url(base_url); return Some(url); } } @@ -65,7 +154,6 @@ fn scan_env_vars(auth_key: &str) -> Option { if let Ok(val) = std::env::var(env_var) { if val == auth_key { if *env_var == "RELIARY_UPSTREAM_URL" { - // The env var IS the URL, not the key return Some(auth_key.to_string()); } return Some(default_url.to_string()); @@ -75,6 +163,22 @@ fn scan_env_vars(auth_key: &str) -> Option { None } +/// Normalize a base URL: append /chat/completions if missing and not Anthropic-style. +fn normalize_url(base_url: &str) -> String { + let trimmed = base_url.trim_end_matches('/'); + if trimmed.ends_with("/chat/completions") || trimmed.ends_with("/v1/messages") || trimmed.contains("/v1/messages") { + trimmed.to_string() + } else if trimmed.starts_with("https://api.anthropic.com") || trimmed.contains("anthropic") { + if trimmed.ends_with("/v1") { + format!("{}/messages", trimmed) + } else { + format!("{}/v1/messages", trimmed) + } + } else { + format!("{}/chat/completions", trimmed) + } +} + /// Resolve an env var reference like "$DEEPSEEK_API_KEY" to its value. fn resolve_env_var(val: &str) -> String { if val.starts_with('$') { @@ -84,6 +188,17 @@ fn resolve_env_var(val: &str) -> String { } } +fn opencode_config_path() -> Option { + let home = home_dir(); + if cfg!(target_os = "windows") { + std::env::var("APPDATA").ok().map(|d| PathBuf::from(d).join("opencode").join("opencode.json")) + } else if cfg!(target_os = "macos") { + Some(home.join("Library/Application Support/opencode/opencode.json")) + } else { + Some(home.join(".config/opencode/opencode.json")) + } +} + fn home_dir() -> PathBuf { std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE"))