| 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"; |
|
|
| |
| #[derive(Debug, Clone, Copy)] |
| enum VariantType { |
| |
| ClaudeThinking, |
| |
| Gemini3Pro, |
| |
| Gemini3Flash, |
| |
| Gemini25Thinking, |
| } |
|
|
| |
| #[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<VariantType>, |
| } |
|
|
| |
| fn build_model_catalog() -> Vec<ModelDef> { |
| vec![ |
| |
| 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), |
| }, |
| |
| 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, |
| }, |
| |
| 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, |
| }, |
| ] |
| } |
|
|
| |
| |
| |
| |
| 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<String>, |
| pub is_synced: bool, |
| pub has_backup: bool, |
| pub current_base_url: Option<String>, |
| pub files: Vec<String>, |
| } |
|
|
| |
| #[derive(Debug, Serialize, Deserialize, Clone)] |
| struct PluginAccount { |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| email: Option<String>, |
| #[serde(rename = "refreshToken")] |
| refresh_token: String, |
| #[serde(default, rename = "projectId", skip_serializing_if = "Option::is_none")] |
| project_id: Option<String>, |
| #[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<HashMap<String, i64>>, |
| |
| #[serde(rename = "managedProjectId", skip_serializing_if = "Option::is_none")] |
| managed_project_id: Option<String>, |
| #[serde(skip_serializing_if = "Option::is_none")] |
| enabled: Option<bool>, |
| #[serde(rename = "lastSwitchReason", skip_serializing_if = "Option::is_none")] |
| last_switch_reason: Option<String>, |
| #[serde(rename = "coolingDownUntil", skip_serializing_if = "Option::is_none")] |
| cooling_down_until: Option<i64>, |
| #[serde(rename = "cooldownReason", skip_serializing_if = "Option::is_none")] |
| cooldown_reason: Option<String>, |
| #[serde(skip_serializing_if = "Option::is_none")] |
| fingerprint: Option<Value>, |
| #[serde(rename = "cachedQuota", skip_serializing_if = "Option::is_none")] |
| cached_quota: Option<Value>, |
| #[serde(rename = "cachedQuotaUpdatedAt", skip_serializing_if = "Option::is_none")] |
| cached_quota_updated_at: Option<i64>, |
| #[serde(rename = "fingerprintHistory", skip_serializing_if = "Option::is_none")] |
| fingerprint_history: Option<Value>, |
| } |
|
|
| |
| #[derive(Debug, Serialize, Deserialize)] |
| struct PluginAccountsFile { |
| version: i32, |
| accounts: Vec<PluginAccount>, |
| #[serde(rename = "activeIndex")] |
| active_index: i32, |
| #[serde(rename = "activeIndexByFamily")] |
| active_index_by_family: HashMap<String, i32>, |
| } |
|
|
| fn get_opencode_dir() -> Option<PathBuf> { |
| 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(); |
| |
| |
| let parts: Vec<&str> = trimmed.split_whitespace().collect(); |
| for part in parts { |
| |
| 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(); |
| } |
| } |
| |
| if is_valid_version(part) { |
| return part.to_string(); |
| } |
| } |
| |
| |
| 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 { |
| |
| 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<PathBuf> { |
| |
| if let Some(path) = find_in_path("opencode") { |
| tracing::debug!("Found opencode in PATH: {:?}", path); |
| return Some(path); |
| } |
| |
| |
| #[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<PathBuf> { |
| |
| 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); |
| } |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| if let Ok(nvm_home) = env::var("NVM_HOME") { |
| if let Some(path) = scan_nvm_directory(&nvm_home) { |
| return Some(path); |
| } |
| } |
| |
| |
| 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<PathBuf> { |
| let home = dirs::home_dir()?; |
| |
| |
| 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()); |
| } |
| } |
| |
| |
| 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()); |
| } |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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<std::path::Path>) -> Option<PathBuf> { |
| 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<std::path::Path>) -> Option<PathBuf> { |
| 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<std::path::Path>) -> Option<PathBuf> { |
| 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<PathBuf> { |
| #[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<String> { |
| let path_str = opencode_path.to_string_lossy(); |
| |
| |
| 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); |
| |
| |
| 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<String> { |
| 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); |
| |
| |
| 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<String>) { |
| 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<String>) { |
| 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(); |
|
|
| |
| let normalized_proxy = normalize_opencode_base_url(proxy_url); |
|
|
| |
| 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()); |
| |
| 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<String, Value>, 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())); |
| } |
| } |
|
|
| |
| fn build_claude_thinking_variant(budget: u32) -> Value { |
| serde_json::json!({ |
| "thinkingConfig": { |
| "thinkingBudget": budget |
| }, |
| "thinking": { |
| "type": "enabled", |
| "budget_tokens": budget, |
| "budgetTokens": budget |
| } |
| }) |
| } |
|
|
| |
| fn build_gemini3_variant(level: &str) -> Value { |
| serde_json::json!({ |
| "thinkingLevel": level |
| }) |
| } |
|
|
| |
| fn build_gemini25_thinking_variant(budget: u32) -> Value { |
| serde_json::json!({ |
| "thinkingConfig": { |
| "thinkingBudget": budget |
| }, |
| "thinking": { |
| "type": "enabled", |
| "budget_tokens": budget, |
| "budgetTokens": budget |
| } |
| }) |
| } |
|
|
| |
| fn build_variants_object(variant_type: Option<VariantType>) -> Option<Value> { |
| 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, |
| } |
| } |
|
|
| |
| 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)); |
| } |
| |
| |
| if let Some(variants) = build_variants_object(model_def.variant_type) { |
| model_obj.insert("variants".to_string(), variants); |
| } |
| |
| Value::Object(model_obj) |
| } |
|
|
| |
| 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) { |
| |
| if let Some(existing_obj) = existing.as_object() { |
| let mut merged = existing_obj.clone(); |
| |
| |
| 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 { |
| |
| models.insert(model_id.to_string(), catalog_model); |
| } |
| } else { |
| |
| 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<Vec<String>>, |
| ) -> 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<Vec<&str>> = 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)?; |
|
|
| |
| let existing_content = if accounts_path.exists() { |
| fs::read_to_string(accounts_path).ok() |
| } else { |
| None |
| }; |
|
|
| |
| let mut existing_accounts_by_refresh_token: HashMap<String, PluginAccount> = HashMap::new(); |
| let mut existing_accounts_by_email: HashMap<String, PluginAccount> = HashMap::new(); |
| let mut existing_active_index: i32 = 0; |
| let mut existing_active_index_by_family: HashMap<String, i32> = HashMap::new(); |
|
|
| if let Some(ref content) = existing_content { |
| if let Ok(existing_json) = serde_json::from_str::<Value>(content) { |
| |
| 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::<PluginAccount>(acc.clone()) { |
| |
| existing_accounts_by_refresh_token.insert(plugin_acc.refresh_token.clone(), plugin_acc.clone()); |
| |
| if let Some(email) = &plugin_acc.email { |
| existing_accounts_by_email.insert(email.clone(), plugin_acc); |
| } |
| } |
| } |
| } |
| |
| 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<PluginAccount> = Vec::new(); |
|
|
| for acc in app_accounts { |
| |
| if acc.disabled || acc.proxy_disabled { |
| continue; |
| } |
|
|
| let refresh_token = acc.token.refresh_token.clone(); |
| let project_id = acc.token.project_id.clone(); |
|
|
| |
| 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 { |
| |
| 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 { |
| |
| 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); |
| } |
|
|
| |
| 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 |
| }; |
|
|
| |
| 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); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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; |
|
|
| |
| 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; |
| } |
|
|
| |
| 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()) |
| } |
| } |
|
|
| |
| |
| 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 |
| } |
|
|
| |
| |
| 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()) { |
| |
| provider.remove(ANTIGRAVITY_PROVIDER_ID); |
|
|
| |
| if clear_legacy { |
| if let Some(proxy) = proxy_url { |
| |
| if let Some(anthropic) = provider.get_mut("anthropic") { |
| cleanup_legacy_provider(anthropic, proxy); |
| } |
|
|
| |
| if let Some(google) = provider.get_mut("google") { |
| cleanup_legacy_provider(google, proxy); |
| } |
| } |
| } |
|
|
| |
| 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() { |
| |
| 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_sync_preserves_existing_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); |
|
|
| |
| 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); |
|
|
| |
| let provider = result.get("provider").unwrap(); |
| let ag = provider.get(ANTIGRAVITY_PROVIDER_ID).unwrap(); |
|
|
| |
| assert_eq!(ag.get("npm").unwrap(), "@ai-sdk/anthropic"); |
| assert_eq!(ag.get("name").unwrap(), "Antigravity Manager"); |
|
|
| |
| 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(); |
|
|
| |
| 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"); |
|
|
| |
| 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"); |
| } |
|
|
| |
|
|
| #[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(); |
|
|
| |
| assert!(!models.contains_key("claude-sonnet-4-5"), "antigravity model should be removed"); |
| |
| 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(); |
|
|
| |
| 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(); |
|
|
| |
| 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" } } |
| } |
| } |
| }); |
|
|
| |
| let result = apply_clear_to_config(config, None, true); |
|
|
| let provider = result.get("provider").unwrap(); |
| let anthropic = provider.get("anthropic").unwrap(); |
|
|
| |
| 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"); |
| } |
|
|
| |
|
|
| #[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); |
|
|
| |
| assert!(result.get("provider").is_none(), "empty provider object should be removed"); |
| } |
| } |
|
|
| pub fn read_opencode_config_content(file_name: Option<String>) -> Result<String, String> { |
| let Some((opencode_path, ag_config_path, ag_accounts_path)) = get_config_paths() else { |
| return Err("Failed to get OpenCode config directory".to_string()); |
| }; |
|
|
| |
| let allowed_files = [ |
| OPENCODE_CONFIG_FILE, |
| ANTIGRAVITY_CONFIG_FILE, |
| ANTIGRAVITY_ACCOUNTS_FILE, |
| ]; |
|
|
| |
| 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, |
| }; |
|
|
| 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<OpencodeStatus, String> { |
| 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<bool>, |
| models: Option<Vec<String>>, |
| ) -> 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<String>, |
| } |
|
|
| #[tauri::command] |
| pub async fn get_opencode_config_content(request: GetOpencodeConfigRequest) -> Result<String, String> { |
| read_opencode_config_content(request.file_name) |
| } |
|
|
| |
| 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", |
| ]; |
|
|
| |
| 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 |
| } |
|
|
| |
| fn clear_opencode_config(proxy_url: Option<String>, 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()); |
| }; |
|
|
| |
| if config_path.exists() { |
| |
| 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); |
|
|
| |
| 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))?; |
| } |
|
|
| |
| 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 from backup")?; |
| } else if accounts_backup_old.exists() { |
| |
| restore_backup_to_target(&accounts_backup_old, &accounts_path, "accounts from old backup")?; |
| } else if accounts_path.exists() { |
| |
| fs::remove_file(&accounts_path) |
| .map_err(|e| format!("Failed to remove accounts file: {}", e))?; |
| } |
|
|
| Ok(()) |
| } |
|
|
| |
| fn cleanup_legacy_provider(provider: &mut Value, proxy_url: &str) { |
| if let Some(provider_obj) = provider.as_object_mut() { |
| |
| 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"); |
| } |
|
|
| |
| 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<String>, |
| clear_legacy: Option<bool>, |
| ) -> Result<(), String> { |
| clear_opencode_config(proxy_url, clear_legacy.unwrap_or(false)) |
| } |
|
|