use std::fmt::Write;
use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde_json::json;
use crate::audit::log_sensitive_event;
use super::super::API_KEYRING_SENTINEL;
use super::super::providers::ApiProvider;
use super::super::types::Config;
use super::paths::{default_config_path, ensure_parent_dir, write_config_file_secure};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SavedCredential {
KeyringAndConfigFile {
backend: String,
path: PathBuf,
},
ConfigFile(PathBuf),
}
impl SavedCredential {
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::KeyringAndConfigFile { backend, path } => {
format!("OS keyring ({backend}) and {}", path.display())
}
Self::ConfigFile(path) => path.display().to_string(),
}
}
}
pub fn save_api_key(api_key: &str) -> Result<SavedCredential> {
let trimmed = api_key.trim();
if trimmed.is_empty() {
anyhow::bail!("Refusing to save an empty API key.");
}
let path = save_api_key_to_config_file(trimmed)?;
#[cfg(not(test))]
{
let secrets = zagens_secrets::Secrets::auto_detect();
match secrets.set("deepseek", trimmed) {
Ok(()) => {
let backend = secrets.backend_name().to_string();
log_sensitive_event(
"credential.save",
json!({
"backend": backend.clone(),
"config_path": path.display().to_string(),
"dual_write": true,
}),
);
return Ok(SavedCredential::KeyringAndConfigFile { backend, path });
}
Err(err) => {
tracing::warn!("OS keyring write failed; key saved to config.toml only: {err}");
}
}
}
Ok(SavedCredential::ConfigFile(path))
}
pub(crate) fn save_api_key_to_config_file(api_key: &str) -> Result<PathBuf> {
fn is_api_key_assignment(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed
.strip_prefix("api_key")
.is_some_and(|rest| rest.trim_start().starts_with('='))
}
let config_path = default_config_path()
.context("Failed to resolve config path: home directory not found.")?;
ensure_parent_dir(&config_path)?;
if !config_path.exists() {
super::paths::ensure_config_file_exists(Some(config_path.clone()))?;
}
let key_to_write = api_key.to_string();
let content = if config_path.exists() {
let existing = fs::read_to_string(&config_path)?;
if existing.contains("api_key") {
let mut result = String::new();
for line in existing.lines() {
if is_api_key_assignment(line) {
let _ = writeln!(result, "api_key = \"{key_to_write}\"");
} else {
result.push_str(line);
result.push('\n');
}
}
result
} else {
format!("api_key = \"{key_to_write}\"\n{existing}")
}
} else {
anyhow::bail!(
"config file missing after ensure_default_on_disk: {}",
config_path.display()
);
};
write_config_file_secure(&config_path, &content)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
log_sensitive_event(
"credential.save",
json!({
"backend": "config_file",
"config_path": config_path.display().to_string(),
}),
);
Ok(config_path)
}
pub fn has_api_key(config: &Config) -> bool {
if std::env::var("DEEPSEEK_API_KEY").is_ok_and(|k| !k.trim().is_empty()) {
return true;
}
if config
.api_key
.as_ref()
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
{
return true;
}
false
}
#[must_use]
pub fn active_provider_has_config_api_key(config: &Config) -> bool {
let provider = config.api_provider();
if config
.provider_config_for(provider)
.and_then(|entry| entry.api_key.as_ref())
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
{
return true;
}
matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
&& config
.api_key
.as_ref()
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
}
#[must_use]
pub fn active_provider_has_env_api_key(config: &Config) -> bool {
match config.api_provider() {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
std::env::var("DEEPSEEK_API_KEY").is_ok_and(|k| !k.trim().is_empty())
}
ApiProvider::NvidiaNim => {
std::env::var("NVIDIA_API_KEY").is_ok_and(|k| !k.trim().is_empty())
|| std::env::var("NVIDIA_NIM_API_KEY").is_ok_and(|k| !k.trim().is_empty())
}
ApiProvider::Openai => std::env::var("OPENAI_API_KEY").is_ok_and(|k| !k.trim().is_empty()),
ApiProvider::Openrouter => {
std::env::var("OPENROUTER_API_KEY").is_ok_and(|k| !k.trim().is_empty())
}
ApiProvider::Novita => std::env::var("NOVITA_API_KEY").is_ok_and(|k| !k.trim().is_empty()),
ApiProvider::Fireworks => {
std::env::var("FIREWORKS_API_KEY").is_ok_and(|k| !k.trim().is_empty())
}
ApiProvider::Sglang => std::env::var("SGLANG_API_KEY").is_ok_and(|k| !k.trim().is_empty()),
ApiProvider::Vllm => std::env::var("VLLM_API_KEY").is_ok_and(|k| !k.trim().is_empty()),
ApiProvider::Ollama => std::env::var("OLLAMA_API_KEY").is_ok_and(|k| !k.trim().is_empty()),
}
}
#[must_use]
pub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {
active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)
}
#[must_use]
pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {
let env_var = match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => "DEEPSEEK_API_KEY",
ApiProvider::NvidiaNim => "NVIDIA_API_KEY",
ApiProvider::Openai => "OPENAI_API_KEY",
ApiProvider::Openrouter => "OPENROUTER_API_KEY",
ApiProvider::Novita => "NOVITA_API_KEY",
ApiProvider::Fireworks => "FIREWORKS_API_KEY",
ApiProvider::Sglang => "SGLANG_API_KEY",
ApiProvider::Vllm => "VLLM_API_KEY",
ApiProvider::Ollama => "OLLAMA_API_KEY",
};
if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {
return true;
}
if matches!(provider, ApiProvider::NvidiaNim)
&& std::env::var("NVIDIA_NIM_API_KEY").is_ok_and(|k| !k.trim().is_empty())
{
return true;
}
if matches!(
provider,
ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama
) {
return true;
}
if config
.provider_config_for(provider)
.and_then(|entry| entry.api_key.as_ref())
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
{
return true;
}
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
&& config
.api_key
.as_ref()
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
{
return true;
}
false
}
pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf> {
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
return match save_api_key(api_key)? {
SavedCredential::KeyringAndConfigFile { path, .. }
| SavedCredential::ConfigFile(path) => Ok(path),
};
}
let config_path = default_config_path()
.context("Failed to resolve config path: home directory not found.")?;
ensure_parent_dir(&config_path)?;
let table_name = match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
return Err(anyhow::anyhow!(
"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage"
));
}
ApiProvider::NvidiaNim => "providers.nvidia_nim",
ApiProvider::Openai => "providers.openai",
ApiProvider::Openrouter => "providers.openrouter",
ApiProvider::Novita => "providers.novita",
ApiProvider::Fireworks => "providers.fireworks",
ApiProvider::Sglang => "providers.sglang",
ApiProvider::Vllm => "providers.vllm",
ApiProvider::Ollama => "providers.ollama",
};
let mut doc: toml::Value = if config_path.exists() {
let raw = fs::read_to_string(&config_path)?;
toml::from_str(&raw)
.with_context(|| format!("Failed to parse config at {}", config_path.display()))?
} else {
toml::Value::Table(toml::value::Table::new())
};
let table = doc
.as_table_mut()
.context("Config root must be a TOML table.")?;
let providers = table
.entry("providers".to_string())
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
.as_table_mut()
.context("`providers` must be a table.")?;
let key_inside = match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
return Err(anyhow::anyhow!(
"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage"
));
}
ApiProvider::NvidiaNim => "nvidia_nim",
ApiProvider::Openai => "openai",
ApiProvider::Openrouter => "openrouter",
ApiProvider::Novita => "novita",
ApiProvider::Fireworks => "fireworks",
ApiProvider::Sglang => "sglang",
ApiProvider::Vllm => "vllm",
ApiProvider::Ollama => "ollama",
};
let entry = providers
.entry(key_inside.to_string())
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
.as_table_mut()
.with_context(|| format!("`{table_name}` must be a table."))?;
entry.insert(
"api_key".to_string(),
toml::Value::String(api_key.to_string()),
);
let serialized = toml::to_string_pretty(&doc).context("failed to serialize updated config")?;
write_config_file_secure(&config_path, &serialized)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
log_sensitive_event(
"credential.save",
json!({
"backend": "config_file",
"provider": provider.as_str(),
"config_path": config_path.display().to_string(),
}),
);
Ok(config_path)
}
pub fn clear_api_key() -> Result<()> {
let config_path = default_config_path()
.context("Failed to resolve config path: home directory not found.")?;
if !config_path.exists() {
return Ok(());
}
let existing = fs::read_to_string(&config_path)?;
let mut result = String::new();
for line in existing.lines() {
let trimmed = line.trim_start();
if trimmed.strip_prefix("api_key").is_some_and(|rest| {
let rest = rest.trim_start();
rest.is_empty() || rest.starts_with('=')
}) {
continue;
}
result.push_str(line);
result.push('\n');
}
write_config_file_secure(&config_path, &result)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
log_sensitive_event(
"credential.clear",
json!({
"backend": "config_file",
"config_path": config_path.display().to_string(),
"scope": "root_and_provider_keys",
}),
);
Ok(())
}