use serde::{Deserialize, Serialize}; use serde_json::Value; use std::path::PathBuf; use std::process::Command; use std::fs; use std::collections::HashMap; use std::env; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x08000000; const OPENCODE_DIR: &str = ".config/opencode"; const OPENCODE_CONFIG_FILE: &str = "opencode.json"; const ANTIGRAVITY_CONFIG_FILE: &str = "antigravity.json"; const ANTIGRAVITY_ACCOUNTS_FILE: &str = "antigravity-accounts.json"; const BACKUP_SUFFIX: &str = ".antigravity-manager.bak"; const OLD_BACKUP_SUFFIX: &str = ".antigravity.bak"; const ANTIGRAVITY_PROVIDER_ID: &str = "antigravity-manager"; /// Variant type for model variants #[derive(Debug, Clone, Copy)] enum VariantType { /// Claude-style thinking with budget_tokens ClaudeThinking, /// Gemini 3 Pro style with thinkingLevel Gemini3Pro, /// Gemini 3 Flash style with thinkingLevel Gemini3Flash, /// Gemini 2.5 thinking style Gemini25Thinking, } /// Model definition with metadata and variants #[derive(Debug, Clone)] struct ModelDef { id: &'static str, name: &'static str, context_limit: u32, output_limit: u32, input_modalities: &'static [&'static str], output_modalities: &'static [&'static str], reasoning: bool, variant_type: Option, } /// Build the complete model catalog for antigravity-manager provider fn build_model_catalog() -> Vec { vec![ // Claude models ModelDef { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", context_limit: 200_000, output_limit: 64_000, input_modalities: &["text", "image", "pdf"], output_modalities: &["text"], reasoning: false, variant_type: None, }, ModelDef { id: "claude-sonnet-4-6-thinking", name: "Claude Sonnet 4.6 Thinking", context_limit: 200_000, output_limit: 64_000, input_modalities: &["text", "image", "pdf"], output_modalities: &["text"], reasoning: true, variant_type: Some(VariantType::ClaudeThinking), }, ModelDef { id: "claude-opus-4-5-thinking", name: "Claude Opus 4.5 Thinking", context_limit: 200_000, output_limit: 64_000, input_modalities: &["text", "image", "pdf"], output_modalities: &["text"], reasoning: true, variant_type: Some(VariantType::ClaudeThinking), }, ModelDef { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking", context_limit: 200_000, output_limit: 64_000, input_modalities: &["text", "image", "pdf"], output_modalities: &["text"], reasoning: true, variant_type: Some(VariantType::ClaudeThinking), }, // Gemini 3.1 Pro models ModelDef { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", context_limit: 1_048_576, output_limit: 65_535, input_modalities: &["text", "image", "pdf"], output_modalities: &["text", "image"], reasoning: true, variant_type: Some(VariantType::Gemini3Pro), }, ModelDef { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", context_limit: 1_048_576, output_limit: 65_535, input_modalities: &["text", "image", "pdf"], output_modalities: &["text", "image"], reasoning: true, variant_type: Some(VariantType::Gemini3Pro), }, ModelDef { id: "gemini-3-flash", name: "Gemini 3 Flash", context_limit: 1_048_576, output_limit: 65_536, input_modalities: &["text", "image", "pdf"], output_modalities: &["text"], reasoning: true, variant_type: Some(VariantType::Gemini3Flash), }, ModelDef { id: "gemini-3-pro-image", name: "Gemini 3 Pro Image", context_limit: 1_048_576, output_limit: 65_535, input_modalities: &["text", "image", "pdf"], output_modalities: &["text", "image"], reasoning: false, variant_type: None, }, // Gemini 2.5 models ModelDef { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", context_limit: 1_048_576, output_limit: 65_536, input_modalities: &["text", "image", "pdf"], output_modalities: &["text"], reasoning: false, variant_type: None, }, ModelDef { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite", context_limit: 1_048_576, output_limit: 65_536, input_modalities: &["text", "image", "pdf"], output_modalities: &["text"], reasoning: false, variant_type: None, }, ModelDef { id: "gemini-2.5-flash-thinking", name: "Gemini 2.5 Flash Thinking", context_limit: 1_048_576, output_limit: 65_536, input_modalities: &["text", "image", "pdf"], output_modalities: &["text"], reasoning: true, variant_type: Some(VariantType::Gemini25Thinking), }, ModelDef { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", context_limit: 1_048_576, output_limit: 65_536, input_modalities: &["text", "image", "pdf"], output_modalities: &["text"], reasoning: true, variant_type: None, }, ] } /// Normalize OpenCode base URL to ensure it ends with `/v1` (Anthropic protocol requirement) /// - Trims trailing `/` /// - If already ends with `/v1`, keeps it as-is /// - Otherwise appends `/v1` fn normalize_opencode_base_url(input: &str) -> String { let trimmed = input.trim().trim_end_matches('/'); if trimmed.ends_with("/v1") { trimmed.to_string() } else { format!("{}/v1", trimmed) } } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct OpencodeStatus { pub installed: bool, pub version: Option, pub is_synced: bool, pub has_backup: bool, pub current_base_url: Option, pub files: Vec, } /// Plugin schema v3 account structure #[derive(Debug, Serialize, Deserialize, Clone)] struct PluginAccount { #[serde(default, skip_serializing_if = "Option::is_none")] email: Option, #[serde(rename = "refreshToken")] refresh_token: String, #[serde(default, rename = "projectId", skip_serializing_if = "Option::is_none")] project_id: Option, #[serde(rename = "addedAt")] added_at: i64, #[serde(rename = "lastUsed")] last_used: i64, #[serde(rename = "rateLimitResetTimes", skip_serializing_if = "Option::is_none")] rate_limit_reset_times: Option>, // Optional preserved state fields #[serde(rename = "managedProjectId", skip_serializing_if = "Option::is_none")] managed_project_id: Option, #[serde(skip_serializing_if = "Option::is_none")] enabled: Option, #[serde(rename = "lastSwitchReason", skip_serializing_if = "Option::is_none")] last_switch_reason: Option, #[serde(rename = "coolingDownUntil", skip_serializing_if = "Option::is_none")] cooling_down_until: Option, #[serde(rename = "cooldownReason", skip_serializing_if = "Option::is_none")] cooldown_reason: Option, #[serde(skip_serializing_if = "Option::is_none")] fingerprint: Option, #[serde(rename = "cachedQuota", skip_serializing_if = "Option::is_none")] cached_quota: Option, #[serde(rename = "cachedQuotaUpdatedAt", skip_serializing_if = "Option::is_none")] cached_quota_updated_at: Option, #[serde(rename = "fingerprintHistory", skip_serializing_if = "Option::is_none")] fingerprint_history: Option, } /// Plugin schema v3 accounts file structure #[derive(Debug, Serialize, Deserialize)] struct PluginAccountsFile { version: i32, accounts: Vec, #[serde(rename = "activeIndex")] active_index: i32, #[serde(rename = "activeIndexByFamily")] active_index_by_family: HashMap, } fn get_opencode_dir() -> Option { dirs::home_dir().map(|h| h.join(OPENCODE_DIR)) } fn get_config_paths() -> Option<(PathBuf, PathBuf, PathBuf)> { get_opencode_dir().map(|dir| { ( dir.join(OPENCODE_CONFIG_FILE), dir.join(ANTIGRAVITY_CONFIG_FILE), dir.join(ANTIGRAVITY_ACCOUNTS_FILE), ) }) } fn extract_version(raw: &str) -> String { let trimmed = raw.trim(); // Try to extract version from formats like "opencode/1.2.3" or "codex-cli 0.86.0" let parts: Vec<&str> = trimmed.split_whitespace().collect(); for part in parts { // Check for format like "opencode/1.2.3" if let Some(slash_idx) = part.find('/') { let after_slash = &part[slash_idx + 1..]; if is_valid_version(after_slash) { return after_slash.to_string(); } } // Check if part itself looks like a version if is_valid_version(part) { return part.to_string(); } } // Fallback: extract last sequence of digits and dots let version_chars: String = trimmed .chars() .skip_while(|c| !c.is_ascii_digit()) .take_while(|c| c.is_ascii_digit() || *c == '.') .collect(); if !version_chars.is_empty() && version_chars.contains('.') { return version_chars; } "unknown".to_string() } fn is_valid_version(s: &str) -> bool { // A valid version should start with digit and contain at least one dot s.chars().next().map_or(false, |c| c.is_ascii_digit()) && s.contains('.') && s.chars().all(|c| c.is_ascii_digit() || c == '.') } fn resolve_opencode_path() -> Option { // First, try to find in PATH if let Some(path) = find_in_path("opencode") { tracing::debug!("Found opencode in PATH: {:?}", path); return Some(path); } // Try fallback locations based on OS #[cfg(target_os = "windows")] { resolve_opencode_path_windows() } #[cfg(not(target_os = "windows"))] { resolve_opencode_path_unix() } } #[cfg(target_os = "windows")] fn resolve_opencode_path_windows() -> Option { // Check npm global location if let Ok(app_data) = env::var("APPDATA") { let npm_opencode_cmd = PathBuf::from(&app_data).join("npm").join("opencode.cmd"); if npm_opencode_cmd.exists() { tracing::debug!("Found opencode.cmd in APPDATA\\npm: {:?}", npm_opencode_cmd); return Some(npm_opencode_cmd); } let npm_opencode_exe = PathBuf::from(&app_data).join("npm").join("opencode.exe"); if npm_opencode_exe.exists() { tracing::debug!("Found opencode.exe in APPDATA\\npm: {:?}", npm_opencode_exe); return Some(npm_opencode_exe); } } // Check pnpm location if let Ok(local_app_data) = env::var("LOCALAPPDATA") { let pnpm_opencode_cmd = PathBuf::from(&local_app_data).join("pnpm").join("opencode.cmd"); if pnpm_opencode_cmd.exists() { tracing::debug!("Found opencode.cmd in LOCALAPPDATA\\pnpm: {:?}", pnpm_opencode_cmd); return Some(pnpm_opencode_cmd); } let pnpm_opencode_exe = PathBuf::from(&local_app_data).join("pnpm").join("opencode.exe"); if pnpm_opencode_exe.exists() { tracing::debug!("Found opencode.exe in LOCALAPPDATA\\pnpm: {:?}", pnpm_opencode_exe); return Some(pnpm_opencode_exe); } } // Check Yarn location if let Ok(local_app_data) = env::var("LOCALAPPDATA") { let yarn_opencode = PathBuf::from(&local_app_data) .join("Yarn") .join("bin") .join("opencode.cmd"); if yarn_opencode.exists() { tracing::debug!("Found opencode.cmd in Yarn bin: {:?}", yarn_opencode); return Some(yarn_opencode); } } // Scan NVM_HOME if let Ok(nvm_home) = env::var("NVM_HOME") { if let Some(path) = scan_nvm_directory(&nvm_home) { return Some(path); } } // Try common NVM locations if let Some(home) = dirs::home_dir() { let nvm_default = home.join(".nvm"); if let Some(path) = scan_nvm_directory(&nvm_default) { return Some(path); } } None } #[cfg(not(target_os = "windows"))] fn resolve_opencode_path_unix() -> Option { let home = dirs::home_dir()?; // Common user bin locations let user_bins = [ home.join(".local").join("bin").join("opencode"), home.join(".npm-global").join("bin").join("opencode"), home.join(".volta").join("bin").join("opencode"), home.join("bin").join("opencode"), ]; for path in &user_bins { if path.exists() { tracing::debug!("Found opencode in user bin: {:?}", path); return Some(path.clone()); } } // System-wide locations let system_bins = [ PathBuf::from("/opt/homebrew/bin/opencode"), PathBuf::from("/usr/local/bin/opencode"), PathBuf::from("/usr/bin/opencode"), ]; for path in &system_bins { if path.exists() { tracing::debug!("Found opencode in system bin: {:?}", path); return Some(path.clone()); } } // Scan nvm directories let nvm_dirs = [ home.join(".nvm").join("versions").join("node"), ]; for nvm_dir in &nvm_dirs { if let Some(path) = scan_node_versions(nvm_dir) { return Some(path); } } // Scan fnm directories let fnm_dirs = [ home.join(".fnm").join("node-versions"), home.join("Library").join("Application Support").join("fnm").join("node-versions"), ]; for fnm_dir in &fnm_dirs { if let Some(path) = scan_fnm_versions(fnm_dir) { return Some(path); } } None } #[cfg(target_os = "windows")] fn scan_nvm_directory(nvm_path: impl AsRef) -> Option { let nvm_path = nvm_path.as_ref(); if !nvm_path.exists() { return None; } let entries = fs::read_dir(nvm_path).ok()?; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { let opencode_cmd = path.join("opencode.cmd"); if opencode_cmd.exists() { tracing::debug!("Found opencode.cmd in NVM: {:?}", opencode_cmd); return Some(opencode_cmd); } let opencode_exe = path.join("opencode.exe"); if opencode_exe.exists() { tracing::debug!("Found opencode.exe in NVM: {:?}", opencode_exe); return Some(opencode_exe); } } } None } #[cfg(not(target_os = "windows"))] fn scan_node_versions(versions_dir: impl AsRef) -> Option { let versions_dir = versions_dir.as_ref(); if !versions_dir.exists() { return None; } let entries = fs::read_dir(versions_dir).ok()?; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { let opencode = path.join("bin").join("opencode"); if opencode.exists() { tracing::debug!("Found opencode in nvm: {:?}", opencode); return Some(opencode); } } } None } #[cfg(not(target_os = "windows"))] fn scan_fnm_versions(versions_dir: impl AsRef) -> Option { let versions_dir = versions_dir.as_ref(); if !versions_dir.exists() { return None; } let entries = fs::read_dir(versions_dir).ok()?; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { let opencode = path.join("installation").join("bin").join("opencode"); if opencode.exists() { tracing::debug!("Found opencode in fnm: {:?}", opencode); return Some(opencode); } } } None } fn find_in_path(executable: &str) -> Option { #[cfg(target_os = "windows")] { let extensions = ["exe", "cmd", "bat"]; if let Ok(path_var) = env::var("PATH") { for dir in path_var.split(';') { for ext in &extensions { let full_path = PathBuf::from(dir).join(format!("{}.{}", executable, ext)); if full_path.exists() { return Some(full_path); } } } } } #[cfg(not(target_os = "windows"))] { if let Ok(path_var) = env::var("PATH") { for dir in path_var.split(':') { let full_path = PathBuf::from(dir).join(executable); if full_path.exists() { return Some(full_path); } } } } None } #[cfg(target_os = "windows")] fn run_opencode_version(opencode_path: &PathBuf) -> Option { let path_str = opencode_path.to_string_lossy(); // Check if it's a .cmd or .bat file that needs cmd.exe let is_cmd = path_str.ends_with(".cmd") || path_str.ends_with(".bat"); let output = if is_cmd { let mut cmd = Command::new("cmd.exe"); cmd.arg("/C") .arg(opencode_path) .arg("--version") .creation_flags(CREATE_NO_WINDOW); cmd.output() } else { let mut cmd = Command::new(opencode_path); cmd.arg("--version") .creation_flags(CREATE_NO_WINDOW); cmd.output() }; match output { Ok(output) if output.status.success() => { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); // Some tools output version to stderr let raw = if stdout.trim().is_empty() { stderr.to_string() } else { stdout.to_string() }; tracing::debug!("opencode --version output: {}", raw.trim()); Some(extract_version(&raw)) } Ok(output) => { tracing::debug!("opencode --version failed with status: {:?}", output.status); None } Err(e) => { tracing::debug!("Failed to run opencode --version: {}", e); None } } } #[cfg(not(target_os = "windows"))] fn run_opencode_version(opencode_path: &PathBuf) -> Option { let output = Command::new(opencode_path) .arg("--version") .output(); match output { Ok(output) if output.status.success() => { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); // Some tools output version to stderr let raw = if stdout.trim().is_empty() { stderr.to_string() } else { stdout.to_string() }; tracing::debug!("opencode --version output: {}", raw.trim()); Some(extract_version(&raw)) } Ok(output) => { tracing::debug!("opencode --version failed with status: {:?}", output.status); None } Err(e) => { tracing::debug!("Failed to run opencode --version: {}", e); None } } } pub fn check_opencode_installed() -> (bool, Option) { tracing::debug!("Checking opencode installation..."); let opencode_path = match resolve_opencode_path() { Some(path) => { tracing::debug!("Resolved opencode path: {:?}", path); path } None => { tracing::debug!("Could not resolve opencode path"); return (false, None); } }; match run_opencode_version(&opencode_path) { Some(version) => { tracing::debug!("opencode version detected: {}", version); (true, Some(version)) } None => { tracing::debug!("Failed to get opencode version"); (false, None) } } } fn get_provider_options<'a>(value: &'a Value, provider_name: &str) -> Option<&'a Value> { value.get("provider") .and_then(|p| p.get(provider_name)) .and_then(|prov| prov.get("options")) } pub fn get_sync_status(proxy_url: &str) -> (bool, bool, Option) { let Some((config_path, _, _)) = get_config_paths() else { return (false, false, None); }; let mut is_synced = true; let mut has_backup = false; let mut current_base_url = None; let backup_path = config_path.with_file_name( format!("{}{}", OPENCODE_CONFIG_FILE, BACKUP_SUFFIX) ); let old_backup_path = config_path.with_file_name( format!("{}{}", OPENCODE_CONFIG_FILE, OLD_BACKUP_SUFFIX) ); if backup_path.exists() || old_backup_path.exists() { has_backup = true; } if !config_path.exists() { return (false, has_backup, None); } let content = match fs::read_to_string(&config_path) { Ok(c) => c, Err(_) => return (false, has_backup, None), }; let json: Value = serde_json::from_str(&content).unwrap_or_default(); // Normalize proxy URL for comparison let normalized_proxy = normalize_opencode_base_url(proxy_url); // Only check antigravity-manager provider let ag_opts = get_provider_options(&json, ANTIGRAVITY_PROVIDER_ID); let ag_url = ag_opts .and_then(|o| o.get("baseURL")) .and_then(|v| v.as_str()); let ag_key = ag_opts .and_then(|o| o.get("apiKey")) .and_then(|v| v.as_str()); if let (Some(url), Some(_key)) = (ag_url, ag_key) { current_base_url = Some(url.to_string()); // Normalize config URL before comparison let normalized_config_url = normalize_opencode_base_url(url); if normalized_config_url != normalized_proxy { is_synced = false; } } else { is_synced = false; } (is_synced, has_backup, current_base_url) } fn create_backup(path: &PathBuf) -> Result<(), String> { if !path.exists() { return Ok(()); } let backup_path = path.with_file_name(format!( "{}{}", path.file_name().unwrap_or_default().to_string_lossy(), BACKUP_SUFFIX )); if backup_path.exists() { return Ok(()); } fs::copy(path, &backup_path) .map_err(|e| format!("Failed to create backup: {}", e))?; Ok(()) } fn restore_backup_to_target(backup_path: &PathBuf, target_path: &PathBuf, label: &str) -> Result<(), String> { if target_path.exists() { fs::remove_file(target_path) .map_err(|e| format!("Failed to remove existing {}: {}", label, e))?; } fs::rename(backup_path, target_path) .map_err(|e| format!("Failed to restore {}: {}", label, e)) } fn ensure_object(value: &mut Value, key: &str) { let needs_reset = match value.get(key) { None => true, Some(v) if !v.is_object() => true, _ => false, }; if needs_reset { value[key] = serde_json::json!({}); } } fn ensure_provider_object(provider: &mut serde_json::Map, name: &str) { let needs_reset = match provider.get(name) { None => true, Some(v) if !v.is_object() => true, _ => false, }; if needs_reset { provider.insert(name.to_string(), serde_json::json!({})); } } fn merge_provider_options(provider: &mut Value, base_url: &str, api_key: &str) { if provider.get("options").is_none() { provider["options"] = serde_json::json!({}); } if let Some(options) = provider.get_mut("options").and_then(|o| o.as_object_mut()) { options.insert("baseURL".to_string(), Value::String(base_url.to_string())); options.insert("apiKey".to_string(), Value::String(api_key.to_string())); } } fn ensure_provider_string_field(provider: &mut Value, key: &str, value: &str) { if let Some(obj) = provider.as_object_mut() { obj.insert(key.to_string(), Value::String(value.to_string())); } } /// Build Claude-style thinking variant with thinkingConfig and thinking fn build_claude_thinking_variant(budget: u32) -> Value { serde_json::json!({ "thinkingConfig": { "thinkingBudget": budget }, "thinking": { "type": "enabled", "budget_tokens": budget, "budgetTokens": budget } }) } /// Build Gemini 3 style variant with thinkingLevel fn build_gemini3_variant(level: &str) -> Value { serde_json::json!({ "thinkingLevel": level }) } /// Build Gemini 2.5 thinking variant with thinkingConfig and thinking fn build_gemini25_thinking_variant(budget: u32) -> Value { serde_json::json!({ "thinkingConfig": { "thinkingBudget": budget }, "thinking": { "type": "enabled", "budget_tokens": budget, "budgetTokens": budget } }) } /// Build variants object based on variant type fn build_variants_object(variant_type: Option) -> Option { match variant_type { Some(VariantType::ClaudeThinking) => { let mut variants = serde_json::Map::new(); variants.insert("low".to_string(), build_claude_thinking_variant(8192)); variants.insert("medium".to_string(), build_claude_thinking_variant(16384)); variants.insert("high".to_string(), build_claude_thinking_variant(24576)); variants.insert("max".to_string(), build_claude_thinking_variant(32768)); Some(Value::Object(variants)) } Some(VariantType::Gemini3Pro) => { let mut variants = serde_json::Map::new(); variants.insert("low".to_string(), build_gemini3_variant("low")); variants.insert("high".to_string(), build_gemini3_variant("high")); Some(Value::Object(variants)) } Some(VariantType::Gemini3Flash) => { let mut variants = serde_json::Map::new(); variants.insert("minimal".to_string(), build_gemini3_variant("minimal")); variants.insert("low".to_string(), build_gemini3_variant("low")); variants.insert("medium".to_string(), build_gemini3_variant("medium")); variants.insert("high".to_string(), build_gemini3_variant("high")); Some(Value::Object(variants)) } Some(VariantType::Gemini25Thinking) => { let mut variants = serde_json::Map::new(); variants.insert("low".to_string(), build_gemini25_thinking_variant(8192)); variants.insert("medium".to_string(), build_gemini25_thinking_variant(12288)); variants.insert("high".to_string(), build_gemini25_thinking_variant(16384)); variants.insert("max".to_string(), build_gemini25_thinking_variant(24576)); Some(Value::Object(variants)) } None => None, } } /// Build model JSON object with full metadata fn build_model_json(model_def: &ModelDef) -> Value { let mut model_obj = serde_json::Map::new(); model_obj.insert("name".to_string(), Value::String(model_def.name.to_string())); let limits = serde_json::json!({ "context": model_def.context_limit, "output": model_def.output_limit, }); model_obj.insert("limit".to_string(), limits); let modalities = serde_json::json!({ "input": model_def.input_modalities, "output": model_def.output_modalities, }); model_obj.insert("modalities".to_string(), modalities); if model_def.reasoning { model_obj.insert("reasoning".to_string(), Value::Bool(true)); } // Build variants as object map instead of array if let Some(variants) = build_variants_object(model_def.variant_type) { model_obj.insert("variants".to_string(), variants); } Value::Object(model_obj) } /// Merge catalog models into provider.models without deleting user models fn merge_catalog_models(provider: &mut Value, model_ids: Option<&[&str]>) { if provider.get("models").is_none() { provider["models"] = serde_json::json!({}); } let catalog = build_model_catalog(); let catalog_map: HashMap<&str, &ModelDef> = catalog.iter().map(|m| (m.id, m)).collect(); if let Some(models) = provider.get_mut("models").and_then(|m| m.as_object_mut()) { let ids_to_sync: Vec<&str> = match model_ids { Some(ids) => ids.to_vec(), None => catalog_map.keys().copied().collect(), }; for model_id in ids_to_sync { if let Some(model_def) = catalog_map.get(model_id) { let catalog_model = build_model_json(model_def); if let Some(existing) = models.get(model_id) { // Merge: keep user-defined fields, update catalog fields if let Some(existing_obj) = existing.as_object() { let mut merged = existing_obj.clone(); // Update/insert catalog fields if let Some(catalog_obj) = catalog_model.as_object() { for (key, value) in catalog_obj.iter() { merged.insert(key.clone(), value.clone()); } } models.insert(model_id.to_string(), Value::Object(merged)); } else { // Existing is not an object, replace with catalog models.insert(model_id.to_string(), catalog_model); } } else { // Model doesn't exist, insert full catalog entry models.insert(model_id.to_string(), catalog_model); } } } } } pub fn sync_opencode_config( proxy_url: &str, api_key: &str, sync_accounts: bool, models_to_sync: Option>, ) -> Result<(), String> { let Some((config_path, _ag_config_path, ag_accounts_path)) = get_config_paths() else { return Err("Failed to get OpenCode config directory".to_string()); }; if let Some(parent) = config_path.parent() { fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?; } create_backup(&config_path)?; let mut config: Value = if config_path.exists() { fs::read_to_string(&config_path) .ok() .and_then(|c| serde_json::from_str(&c).ok()) .unwrap_or_else(|| serde_json::json!({})) } else { serde_json::json!({}) }; let model_refs: Option> = models_to_sync .as_ref() .map(|models| models.iter().map(|m| m.as_str()).collect()); config = apply_sync_to_config(config, proxy_url, api_key, model_refs.as_deref()); let tmp_path = config_path.with_extension("tmp"); fs::write(&tmp_path, serde_json::to_string_pretty(&config).unwrap()) .map_err(|e| format!("Failed to write temp file: {}", e))?; fs::rename(&tmp_path, &config_path) .map_err(|e| format!("Failed to rename config file: {}", e))?; if sync_accounts { sync_accounts_file(&ag_accounts_path)?; } Ok(()) } fn sync_accounts_file(accounts_path: &PathBuf) -> Result<(), String> { create_backup(accounts_path)?; // Read existing file for state preservation let existing_content = if accounts_path.exists() { fs::read_to_string(accounts_path).ok() } else { None }; // Parse existing accounts for state preservation (match by refresh_token first, then email) let mut existing_accounts_by_refresh_token: HashMap = HashMap::new(); let mut existing_accounts_by_email: HashMap = HashMap::new(); let mut existing_active_index: i32 = 0; let mut existing_active_index_by_family: HashMap = HashMap::new(); if let Some(ref content) = existing_content { if let Ok(existing_json) = serde_json::from_str::(content) { // Parse existing accounts if let Some(existing_accounts) = existing_json.get("accounts").and_then(|a| a.as_array()) { for acc in existing_accounts { if let Ok(plugin_acc) = serde_json::from_value::(acc.clone()) { // Index by refresh_token (primary key for matching) existing_accounts_by_refresh_token.insert(plugin_acc.refresh_token.clone(), plugin_acc.clone()); // Index by email (fallback) if let Some(email) = &plugin_acc.email { existing_accounts_by_email.insert(email.clone(), plugin_acc); } } } } // Parse existing active indices if let Some(idx) = existing_json.get("activeIndex").and_then(|v| v.as_i64()) { existing_active_index = idx as i32; } if let Some(family_indices) = existing_json.get("activeIndexByFamily").and_then(|v| v.as_object()) { for (key, val) in family_indices { if let Some(idx) = val.as_i64() { existing_active_index_by_family.insert(key.clone(), idx as i32); } } } } } let app_accounts = crate::modules::account::list_accounts() .map_err(|e| format!("Failed to list accounts: {}", e))?; let mut new_accounts: Vec = Vec::new(); for acc in app_accounts { // Skip disabled accounts (preserve existing logic) if acc.disabled || acc.proxy_disabled { continue; } let refresh_token = acc.token.refresh_token.clone(); let project_id = acc.token.project_id.clone(); // Try to find existing account state (match by refresh_token first, then email fallback) let existing = existing_accounts_by_refresh_token .get(&refresh_token) .cloned() .or_else(|| existing_accounts_by_email.get(&acc.email).cloned()); let plugin_account = if let Some(existing) = existing { // Preserve existing state PluginAccount { email: Some(acc.email), refresh_token, project_id, added_at: existing.added_at, last_used: existing.last_used.max(acc.last_used), rate_limit_reset_times: existing.rate_limit_reset_times, managed_project_id: existing.managed_project_id, enabled: existing.enabled, last_switch_reason: existing.last_switch_reason, cooling_down_until: existing.cooling_down_until, cooldown_reason: existing.cooldown_reason, fingerprint: existing.fingerprint, cached_quota: existing.cached_quota, cached_quota_updated_at: existing.cached_quota_updated_at, fingerprint_history: existing.fingerprint_history, } } else { // New account - use defaults let now = chrono::Utc::now().timestamp_millis(); PluginAccount { email: Some(acc.email), refresh_token, project_id, added_at: now, last_used: acc.last_used, rate_limit_reset_times: None, managed_project_id: None, enabled: None, last_switch_reason: None, cooling_down_until: None, cooldown_reason: None, fingerprint: None, cached_quota: None, cached_quota_updated_at: None, fingerprint_history: None, } }; new_accounts.push(plugin_account); } // Clamp activeIndex to valid range let account_count = new_accounts.len() as i32; let clamped_active_index = if account_count > 0 { existing_active_index.clamp(0, account_count - 1) } else { 0 }; // Clamp activeIndexByFamily values let mut clamped_active_index_by_family = HashMap::new(); for (family, idx) in existing_active_index_by_family { let clamped_idx = if account_count > 0 { idx.clamp(0, account_count - 1) } else { 0 }; clamped_active_index_by_family.insert(family, clamped_idx); } // Ensure family indices always exist for plugin v3 behavior. if !clamped_active_index_by_family.contains_key("claude") { clamped_active_index_by_family.insert("claude".to_string(), clamped_active_index); } if !clamped_active_index_by_family.contains_key("gemini") { clamped_active_index_by_family.insert("gemini".to_string(), clamped_active_index); } // Build schema v3 output let new_data = PluginAccountsFile { version: 3, accounts: new_accounts, active_index: clamped_active_index, active_index_by_family: clamped_active_index_by_family, }; let tmp_path = accounts_path.with_extension("tmp"); fs::write(&tmp_path, serde_json::to_string_pretty(&new_data).unwrap()) .map_err(|e| format!("Failed to write accounts temp file: {}", e))?; fs::rename(&tmp_path, accounts_path) .map_err(|e| format!("Failed to rename accounts file: {}", e))?; Ok(()) } pub fn restore_opencode_config() -> Result<(), String> { let Some((config_path, _, accounts_path)) = get_config_paths() else { return Err("Failed to get OpenCode config directory".to_string()); }; let mut restored = false; // Try new backup suffix first, fall back to old suffix for backward compatibility let config_backup_new = config_path.with_file_name(format!( "{}{}", OPENCODE_CONFIG_FILE, BACKUP_SUFFIX )); let config_backup_old = config_path.with_file_name(format!( "{}{}", OPENCODE_CONFIG_FILE, OLD_BACKUP_SUFFIX )); if config_backup_new.exists() { restore_backup_to_target(&config_backup_new, &config_path, "config")?; restored = true; } else if config_backup_old.exists() { restore_backup_to_target(&config_backup_old, &config_path, "config")?; restored = true; } // Try new backup suffix first, fall back to old suffix for backward compatibility let accounts_backup_new = accounts_path.with_file_name(format!( "{}{}", ANTIGRAVITY_ACCOUNTS_FILE, BACKUP_SUFFIX )); let accounts_backup_old = accounts_path.with_file_name(format!( "{}{}", ANTIGRAVITY_ACCOUNTS_FILE, OLD_BACKUP_SUFFIX )); if accounts_backup_new.exists() { restore_backup_to_target(&accounts_backup_new, &accounts_path, "accounts")?; restored = true; } else if accounts_backup_old.exists() { restore_backup_to_target(&accounts_backup_old, &accounts_path, "accounts")?; restored = true; } if restored { Ok(()) } else { Err("No backup files found".to_string()) } } /// Pure function: Apply sync logic to config JSON /// Returns the modified config Value fn apply_sync_to_config( mut config: Value, proxy_url: &str, api_key: &str, models_to_sync: Option<&[&str]>, ) -> Value { if !config.is_object() { config = serde_json::json!({}); } if config.get("$schema").is_none() { config["$schema"] = Value::String("https://opencode.ai/config.json".to_string()); } let normalized_url = normalize_opencode_base_url(proxy_url); ensure_object(&mut config, "provider"); if let Some(provider) = config.get_mut("provider").and_then(|p| p.as_object_mut()) { ensure_provider_object(provider, ANTIGRAVITY_PROVIDER_ID); if let Some(ag_provider) = provider.get_mut(ANTIGRAVITY_PROVIDER_ID) { ensure_provider_string_field(ag_provider, "npm", "@ai-sdk/anthropic"); ensure_provider_string_field(ag_provider, "name", "Antigravity Manager"); merge_provider_options(ag_provider, &normalized_url, api_key); merge_catalog_models(ag_provider, models_to_sync); } } config } /// Pure function: Apply clear logic to config JSON /// Returns the modified config Value fn apply_clear_to_config( mut config: Value, proxy_url: Option<&str>, clear_legacy: bool, ) -> Value { if let Some(provider) = config.get_mut("provider").and_then(|p| p.as_object_mut()) { // 1. Remove antigravity-manager provider provider.remove(ANTIGRAVITY_PROVIDER_ID); // 2. Cleanup legacy entries if requested if clear_legacy { if let Some(proxy) = proxy_url { // Clean up provider.anthropic if let Some(anthropic) = provider.get_mut("anthropic") { cleanup_legacy_provider(anthropic, proxy); } // Clean up provider.google if let Some(google) = provider.get_mut("google") { cleanup_legacy_provider(google, proxy); } } } // Remove empty provider object if it has no entries if provider.is_empty() { if let Some(config_obj) = config.as_object_mut() { config_obj.remove("provider"); } } } config } #[cfg(test)] mod tests { use super::*; #[test] fn test_extract_version_opencode_format() { let input = "opencode/1.2.3"; assert_eq!(extract_version(input), "1.2.3"); } #[test] fn test_extract_version_codex_cli_format() { let input = "codex-cli 0.86.0\n"; assert_eq!(extract_version(input), "0.86.0"); } #[test] fn test_extract_version_simple() { let input = "v2.0.1"; assert_eq!(extract_version(input), "2.0.1"); } #[test] fn test_extract_version_unknown() { let input = "some random text without version"; assert_eq!(extract_version(input), "unknown"); } #[test] fn test_normalize_opencode_base_url_without_v1() { assert_eq!(normalize_opencode_base_url("http://localhost:3000"), "http://localhost:3000/v1"); assert_eq!(normalize_opencode_base_url("http://localhost:3000/"), "http://localhost:3000/v1"); } #[test] fn test_normalize_opencode_base_url_with_v1() { assert_eq!(normalize_opencode_base_url("http://localhost:3000/v1"), "http://localhost:3000/v1"); assert_eq!(normalize_opencode_base_url("http://localhost:3000/v1/"), "http://localhost:3000/v1"); } #[test] fn test_normalize_opencode_base_url_with_whitespace() { assert_eq!(normalize_opencode_base_url(" http://localhost:3000 "), "http://localhost:3000/v1"); assert_eq!(normalize_opencode_base_url(" http://localhost:3000/v1 "), "http://localhost:3000/v1"); } #[test] fn test_normalize_opencode_base_url_no_double_v1() { // Ensure we don't create double /v1/v1 assert_eq!(normalize_opencode_base_url("http://localhost:3000/v1"), "http://localhost:3000/v1"); assert_eq!(normalize_opencode_base_url("http://localhost:3000/v1/"), "http://localhost:3000/v1"); } // Tests for apply_sync_to_config #[test] fn test_sync_preserves_existing_providers() { // Config with existing google and anthropic providers let config = serde_json::json!({ "provider": { "google": { "options": { "apiKey": "google-key" }, "models": { "gemini-pro": { "name": "Gemini Pro" } } }, "anthropic": { "options": { "apiKey": "anthropic-key" }, "models": { "claude-3": { "name": "Claude 3" } } } } }); let result = apply_sync_to_config(config, "http://localhost:3000", "test-api-key", None); // Existing providers should be preserved let provider = result.get("provider").unwrap(); assert!(provider.get("google").is_some(), "google provider should be preserved"); assert!(provider.get("anthropic").is_some(), "anthropic provider should be preserved"); assert_eq!( provider.get("google").unwrap().get("options").unwrap().get("apiKey").unwrap(), "google-key" ); assert_eq!( provider.get("anthropic").unwrap().get("options").unwrap().get("apiKey").unwrap(), "anthropic-key" ); } #[test] fn test_sync_creates_antigravity_provider() { let config = serde_json::json!({}); let result = apply_sync_to_config(config, "http://localhost:3000", "test-api-key", None); // antigravity-manager provider should be created let provider = result.get("provider").unwrap(); let ag = provider.get(ANTIGRAVITY_PROVIDER_ID).unwrap(); // Check npm and name assert_eq!(ag.get("npm").unwrap(), "@ai-sdk/anthropic"); assert_eq!(ag.get("name").unwrap(), "Antigravity Manager"); // Check options let options = ag.get("options").unwrap(); assert_eq!(options.get("baseURL").unwrap(), "http://localhost:3000/v1"); assert_eq!(options.get("apiKey").unwrap(), "test-api-key"); } #[test] fn test_sync_creates_models() { let config = serde_json::json!({}); let result = apply_sync_to_config(config, "http://localhost:3000", "test-api-key", None); let provider = result.get("provider").unwrap(); let ag = provider.get(ANTIGRAVITY_PROVIDER_ID).unwrap(); let models = ag.get("models").unwrap().as_object().unwrap(); // Should have all catalog models assert!(models.contains_key("claude-sonnet-4-6"), "should have claude-sonnet-4-6"); assert!(models.contains_key("gemini-3.1-pro-high"), "should have gemini-3.1-pro-high"); assert!(models.contains_key("gemini-2.5-pro"), "should have gemini-2.5-pro"); // Check model structure let claude_model = models.get("claude-sonnet-4-6").unwrap(); assert_eq!(claude_model.get("name").unwrap(), "Claude Sonnet 4.6"); assert!(claude_model.get("limit").is_some()); assert!(claude_model.get("modalities").is_some()); } #[test] fn test_sync_with_filtered_models() { let config = serde_json::json!({}); let models_to_sync = &["claude-sonnet-4-6", "gemini-3.1-pro-high"]; let result = apply_sync_to_config(config, "http://localhost:3000", "test-api-key", Some(models_to_sync)); let provider = result.get("provider").unwrap(); let ag = provider.get(ANTIGRAVITY_PROVIDER_ID).unwrap(); let models = ag.get("models").unwrap().as_object().unwrap(); assert!(models.contains_key("claude-sonnet-4-6")); assert!(models.contains_key("gemini-3.1-pro-high")); assert!(!models.contains_key("gemini-2.5-pro"), "should not have unselected models"); } // Tests for apply_clear_to_config #[test] fn test_clear_removes_antigravity_provider() { let config = serde_json::json!({ "provider": { "antigravity-manager": { "options": { "baseURL": "http://localhost:3000/v1" } }, "google": { "options": { "apiKey": "key" } } } }); let result = apply_clear_to_config(config, None, false); let provider = result.get("provider").unwrap(); assert!(provider.get(ANTIGRAVITY_PROVIDER_ID).is_none(), "antigravity-manager should be removed"); assert!(provider.get("google").is_some(), "google should be preserved"); } #[test] fn test_clear_legacy_removes_antigravity_models() { let config = serde_json::json!({ "provider": { "anthropic": { "options": { "baseURL": "http://localhost:3000/v1", "apiKey": "key" }, "models": { "claude-sonnet-4-5": { "name": "Claude" }, "claude-3": { "name": "Claude 3" } } } } }); let result = apply_clear_to_config(config, Some("http://localhost:3000"), true); let provider = result.get("provider").unwrap(); let anthropic = provider.get("anthropic").unwrap(); let models = anthropic.get("models").unwrap().as_object().unwrap(); // Antigravity model IDs should be removed assert!(!models.contains_key("claude-sonnet-4-5"), "antigravity model should be removed"); // Non-antigravity models should be preserved assert!(models.contains_key("claude-3"), "non-antigravity model should be preserved"); } #[test] fn test_clear_legacy_removes_options_when_baseurl_matches() { let config = serde_json::json!({ "provider": { "anthropic": { "options": { "baseURL": "http://localhost:3000/v1", "apiKey": "key" } } } }); let result = apply_clear_to_config(config, Some("http://localhost:3000"), true); let provider = result.get("provider").unwrap(); let anthropic = provider.get("anthropic").unwrap(); // Options should be removed when baseURL matches assert!(anthropic.get("options").is_none(), "options should be removed when baseURL matches"); } #[test] fn test_clear_legacy_preserves_options_when_baseurl_different() { let config = serde_json::json!({ "provider": { "anthropic": { "options": { "baseURL": "http://other-proxy.com/v1", "apiKey": "key" } } } }); let result = apply_clear_to_config(config, Some("http://localhost:3000"), true); let provider = result.get("provider").unwrap(); let anthropic = provider.get("anthropic").unwrap(); let options = anthropic.get("options").unwrap(); // Options should be preserved when baseURL doesn't match assert_eq!(options.get("baseURL").unwrap(), "http://other-proxy.com/v1"); assert_eq!(options.get("apiKey").unwrap(), "key"); } #[test] fn test_clear_legacy_without_proxy_url_skips_cleanup() { let config = serde_json::json!({ "provider": { "anthropic": { "options": { "baseURL": "http://localhost:3000/v1", "apiKey": "key" }, "models": { "claude-sonnet-4-5": { "name": "Claude" } } } } }); // clear_legacy=true but no proxy_url provided let result = apply_clear_to_config(config, None, true); let provider = result.get("provider").unwrap(); let anthropic = provider.get("anthropic").unwrap(); // Legacy cleanup should be skipped when proxy_url is None assert!(anthropic.get("options").is_some(), "options should be preserved when no proxy_url"); assert!(anthropic.get("models").is_some(), "models should be preserved when no proxy_url"); } // Tests for base_url_matches #[test] fn test_base_url_matches_with_v1() { assert!(base_url_matches("http://localhost:3000/v1", "http://localhost:3000")); assert!(base_url_matches("http://localhost:3000", "http://localhost:3000/v1")); assert!(base_url_matches("http://localhost:3000/v1/", "http://localhost:3000")); } #[test] fn test_base_url_matches_without_v1() { assert!(base_url_matches("http://localhost:3000", "http://localhost:3000")); assert!(base_url_matches("http://localhost:3000/", "http://localhost:3000/")); } #[test] fn test_base_url_matches_different_urls() { assert!(!base_url_matches("http://localhost:3000", "http://other-host:3000")); assert!(!base_url_matches("http://localhost:3000/v1", "http://localhost:4000/v1")); } #[test] fn test_clear_removes_empty_provider() { let config = serde_json::json!({ "provider": { "antigravity-manager": { "options": { "baseURL": "http://localhost:3000/v1" } } } }); let result = apply_clear_to_config(config, None, false); // Provider object should be removed when empty assert!(result.get("provider").is_none(), "empty provider object should be removed"); } } pub fn read_opencode_config_content(file_name: Option) -> Result { let Some((opencode_path, ag_config_path, ag_accounts_path)) = get_config_paths() else { return Err("Failed to get OpenCode config directory".to_string()); }; // Allowlist of permitted file names let allowed_files = [ OPENCODE_CONFIG_FILE, ANTIGRAVITY_CONFIG_FILE, ANTIGRAVITY_ACCOUNTS_FILE, ]; // Determine which file to read let target_path = match file_name.as_deref() { Some(name) if name == ANTIGRAVITY_CONFIG_FILE => ag_config_path, Some(name) if name == ANTIGRAVITY_ACCOUNTS_FILE => ag_accounts_path, Some(name) if name == OPENCODE_CONFIG_FILE => opencode_path, Some(name) => { return Err(format!( "Invalid file name: {}. Allowed: {:?}", name, allowed_files )) } None => opencode_path, // Default to opencode.json }; if !target_path.exists() { return Err(format!("Config file does not exist: {:?}", target_path)); } fs::read_to_string(&target_path) .map_err(|e| format!("Failed to read config: {}", e)) } #[tauri::command] pub async fn get_opencode_sync_status(proxy_url: String) -> Result { let (installed, version) = check_opencode_installed(); let (is_synced, has_backup, current_base_url) = get_sync_status(&proxy_url); Ok(OpencodeStatus { installed, version, is_synced, has_backup, current_base_url, files: vec![ OPENCODE_CONFIG_FILE.to_string(), ANTIGRAVITY_CONFIG_FILE.to_string(), ANTIGRAVITY_ACCOUNTS_FILE.to_string(), ], }) } #[tauri::command] pub async fn execute_opencode_sync( proxy_url: String, api_key: String, sync_accounts: Option, models: Option>, ) -> Result<(), String> { sync_opencode_config(&proxy_url, &api_key, sync_accounts.unwrap_or(false), models) } #[tauri::command] pub async fn execute_opencode_restore() -> Result<(), String> { restore_opencode_config() } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetOpencodeConfigRequest { pub file_name: Option, } #[tauri::command] pub async fn get_opencode_config_content(request: GetOpencodeConfigRequest) -> Result { read_opencode_config_content(request.file_name) } /// List of Antigravity model IDs that may have been added to legacy providers const ANTIGRAVITY_MODEL_IDS: &[&str] = &[ "claude-sonnet-4-6", "claude-sonnet-4-6-thinking", "claude-sonnet-4-5", "claude-sonnet-4-5-thinking", "claude-opus-4-5-thinking", "gemini-3.1-pro-high", "gemini-3.1-pro-low", "gemini-3-pro-high", "gemini-3-pro-low", "gemini-3-flash", "gemini-3-pro-image", "gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-2.5-flash-thinking", "gemini-2.5-pro", ]; /// Check if a base URL matches the proxy URL (supports both with and without /v1) fn base_url_matches(config_url: &str, proxy_url: &str) -> bool { let normalized_config = normalize_opencode_base_url(config_url); let normalized_proxy = normalize_opencode_base_url(proxy_url); normalized_config == normalized_proxy } /// Clear OpenCode config by removing antigravity-manager provider and optionally cleaning up legacy entries fn clear_opencode_config(proxy_url: Option, clear_legacy: bool) -> Result<(), String> { let Some((config_path, _, accounts_path)) = get_config_paths() else { return Err("Failed to get OpenCode config directory".to_string()); }; // Process opencode.json if config_path.exists() { // Create backup before modifying create_backup(&config_path)?; let content = fs::read_to_string(&config_path) .map_err(|e| format!("Failed to read config: {}", e))?; let config: Value = serde_json::from_str(&content) .map_err(|e| format!("Failed to parse config: {}", e))?; let config = apply_clear_to_config(config, proxy_url.as_deref(), clear_legacy); // Write updated config let tmp_path = config_path.with_extension("tmp"); fs::write(&tmp_path, serde_json::to_string_pretty(&config).unwrap()) .map_err(|e| format!("Failed to write temp file: {}", e))?; fs::rename(&tmp_path, &config_path) .map_err(|e| format!("Failed to rename config file: {}", e))?; } // Process antigravity-accounts.json let accounts_backup_new = accounts_path.with_file_name(format!( "{}{}", ANTIGRAVITY_ACCOUNTS_FILE, BACKUP_SUFFIX )); let accounts_backup_old = accounts_path.with_file_name(format!( "{}{}", ANTIGRAVITY_ACCOUNTS_FILE, OLD_BACKUP_SUFFIX )); if accounts_backup_new.exists() { // Restore from new backup restore_backup_to_target(&accounts_backup_new, &accounts_path, "accounts from backup")?; } else if accounts_backup_old.exists() { // Restore from old backup restore_backup_to_target(&accounts_backup_old, &accounts_path, "accounts from old backup")?; } else if accounts_path.exists() { // No backup found, delete the file fs::remove_file(&accounts_path) .map_err(|e| format!("Failed to remove accounts file: {}", e))?; } Ok(()) } /// Cleanup legacy provider entries (anthropic/google) that were configured by old versions fn cleanup_legacy_provider(provider: &mut Value, proxy_url: &str) { if let Some(provider_obj) = provider.as_object_mut() { // Remove Antigravity model IDs from models list. let remove_models_key = if let Some(models) = provider_obj.get_mut("models").and_then(|m| m.as_object_mut()) { for model_id in ANTIGRAVITY_MODEL_IDS { models.remove(*model_id); } models.is_empty() } else { false }; if remove_models_key { provider_obj.remove("models"); } // Check and remove options.baseURL and options.apiKey if baseURL matches proxy. let remove_options_key = if let Some(options) = provider_obj.get_mut("options").and_then(|o| o.as_object_mut()) { let should_cleanup = options .get("baseURL") .and_then(|v| v.as_str()) .map(|base_url| base_url_matches(base_url, proxy_url)) .unwrap_or(false); if should_cleanup { options.remove("baseURL"); options.remove("apiKey"); } options.is_empty() } else { false }; if remove_options_key { provider_obj.remove("options"); } } } #[tauri::command] pub async fn execute_opencode_clear( proxy_url: Option, clear_legacy: Option, ) -> Result<(), String> { clear_opencode_config(proxy_url, clear_legacy.unwrap_or(false)) }