use serde::{de, Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use std::fs;
use crate::error::{ButterflyBotError, Result};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OpenAiConfig {
pub api_key: Option<String>,
pub model: Option<String>,
pub base_url: Option<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeProvider {
Ollama,
Openai,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderConfig {
pub runtime: RuntimeProvider,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MemoryConfig {
pub enabled: Option<bool>,
pub sqlite_path: Option<String>,
pub summary_model: Option<String>,
pub embedding_model: Option<String>,
pub rerank_model: Option<String>,
pub openai: Option<OpenAiConfig>,
pub context_embed_enabled: Option<bool>,
pub summary_threshold: Option<usize>,
pub retention_days: Option<u32>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MarkdownSource {
Url { url: String },
Database { markdown: String },
}
impl MarkdownSource {
pub fn default_heartbeat() -> Self {
Self::Database {
markdown: "# Heartbeat\n\nStay proactive, grounded, and transparent. Prefer clear next steps and avoid over-claiming.".to_string(),
}
}
pub fn default_prompt() -> Self {
Self::Database {
markdown: "# Prompt\n\nAnswer directly, include concrete actions, and keep responses practical.".to_string(),
}
}
pub fn as_url(&self) -> Option<&str> {
match self {
Self::Url { url } => Some(url.as_str()),
Self::Database { .. } => None,
}
}
pub fn as_database_markdown(&self) -> Option<&str> {
match self {
Self::Url { .. } => None,
Self::Database { markdown } => Some(markdown.as_str()),
}
}
fn from_legacy_string(value: String) -> Self {
let trimmed = value.trim();
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return Self::Url {
url: trimmed.to_string(),
};
}
let markdown = fs::read_to_string(trimmed).unwrap_or_default();
Self::Database { markdown }
}
fn from_json_value(value: Value) -> std::result::Result<Self, String> {
match value {
Value::String(raw) => Ok(Self::from_legacy_string(raw)),
Value::Object(map) => {
if let Some(kind) = map.get("type").and_then(|v| v.as_str()) {
match kind {
"url" => {
let url = map
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| "url source requires `url`".to_string())?;
Ok(Self::Url {
url: url.to_string(),
})
}
"database" => {
let markdown = map
.get("markdown")
.and_then(|v| v.as_str())
.unwrap_or_default();
Ok(Self::Database {
markdown: markdown.to_string(),
})
}
other => Err(format!("unsupported markdown source type: {other}")),
}
} else if let Some(url) = map.get("url").and_then(|v| v.as_str()) {
Ok(Self::Url {
url: url.to_string(),
})
} else if let Some(markdown) = map.get("markdown").and_then(|v| v.as_str()) {
Ok(Self::Database {
markdown: markdown.to_string(),
})
} else {
Err(
"markdown source object must include `type` or (`url`/`markdown`)"
.to_string(),
)
}
}
Value::Null => Err("markdown source cannot be null".to_string()),
other => Err(format!("invalid markdown source: {other}")),
}
}
}
impl<'de> Deserialize<'de> for MarkdownSource {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
Self::from_json_value(value).map_err(de::Error::custom)
}
}
fn default_heartbeat_source() -> MarkdownSource {
MarkdownSource::default_heartbeat()
}
fn default_prompt_source() -> MarkdownSource {
MarkdownSource::default_prompt()
}
fn deserialize_heartbeat_source<'de, D>(
deserializer: D,
) -> std::result::Result<MarkdownSource, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
match value {
None | Some(Value::Null) => Ok(default_heartbeat_source()),
Some(value) => MarkdownSource::from_json_value(value).map_err(de::Error::custom),
}
}
fn deserialize_prompt_source<'de, D>(
deserializer: D,
) -> std::result::Result<MarkdownSource, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
match value {
None | Some(Value::Null) => Ok(default_prompt_source()),
Some(value) => MarkdownSource::from_json_value(value).map_err(de::Error::custom),
}
}
fn is_legacy_ollama_base_url(base_url: &str) -> bool {
let normalized = base_url.trim().to_ascii_lowercase();
normalized.starts_with("http://localhost:11434")
|| normalized.starts_with("http://127.0.0.1:11434")
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub provider: Option<ProviderConfig>,
pub openai: Option<OpenAiConfig>,
#[serde(
default = "default_heartbeat_source",
alias = "heartbeat_file",
deserialize_with = "deserialize_heartbeat_source"
)]
pub heartbeat_source: MarkdownSource,
#[serde(
default = "default_prompt_source",
alias = "prompt_file",
deserialize_with = "deserialize_prompt_source"
)]
pub prompt_source: MarkdownSource,
pub memory: Option<MemoryConfig>,
pub tools: Option<Value>,
pub brains: Option<Value>,
}
impl Config {
pub fn runtime_provider(&self) -> RuntimeProvider {
RuntimeProvider::Openai
}
fn apply_security_defaults(mut self) -> Self {
if let Some(openai) = self.openai.as_mut() {
if let Some(base_url) = openai.base_url.as_deref() {
if is_legacy_ollama_base_url(base_url) {
openai.base_url = Some("https://api.openai.com/v1".to_string());
}
}
let migrate_model = openai
.model
.as_deref()
.map(|model| {
let model = model.trim();
model.eq_ignore_ascii_case("gpt-5.2")
|| model.eq_ignore_ascii_case("gpt-5-mini")
})
.unwrap_or(true);
if migrate_model {
openai.model = Some("gpt-4.1-mini".to_string());
}
}
if let Some(memory) = self.memory.as_mut() {
let drop_memory_openai = memory
.openai
.as_ref()
.and_then(|cfg| cfg.base_url.as_deref())
.map(is_legacy_ollama_base_url)
.unwrap_or(false);
if drop_memory_openai {
memory.openai = None;
}
}
let tools = self.tools.get_or_insert_with(|| Value::Object(Map::new()));
if let Some(tools_obj) = tools.as_object_mut() {
let settings = tools_obj
.entry("settings")
.or_insert_with(|| Value::Object(Map::new()));
if let Some(settings_obj) = settings.as_object_mut() {
let permissions = settings_obj
.entry("permissions")
.or_insert_with(|| Value::Object(Map::new()));
if let Some(perms_obj) = permissions.as_object_mut() {
perms_obj
.entry("default_deny")
.or_insert_with(|| Value::Bool(true));
perms_obj.entry("network_allow").or_insert_with(|| {
Value::Array(vec![
Value::String("localhost".to_string()),
Value::String("127.0.0.1".to_string()),
Value::String("api.openai.com".to_string()),
Value::String("api.x.ai".to_string()),
Value::String("api.perplexity.ai".to_string()),
Value::String("api.githubcopilot.com".to_string()),
Value::String("mcp.zapier.com".to_string()),
])
});
}
let security = settings_obj
.entry("security")
.or_insert_with(|| Value::Object(Map::new()));
if let Some(security_obj) = security.as_object_mut() {
security_obj
.entry("tpm_mode")
.or_insert_with(|| Value::String("auto".to_string()));
}
let solana = settings_obj
.entry("solana")
.or_insert_with(|| Value::Object(Map::new()));
if let Some(solana_obj) = solana.as_object_mut() {
solana_obj.entry("rpc").or_insert_with(|| {
serde_json::json!({
"provider": "quicknode",
"endpoint": "",
"commitment": "confirmed",
"bootstrap_wallets": [
{
"user_id": "user",
"actor": "agent"
}
],
"compute_budget": {
"unit_limit": 300000,
"unit_price_microlamports": 2500
},
"simulation": {
"enabled": true,
"commitment": "processed",
"replace_recent_blockhash": true,
"sig_verify": false,
"min_context_slot": null
},
"send": {
"skip_preflight": false,
"preflight_commitment": "confirmed",
"max_retries": 5
}
})
});
}
}
}
self
}
pub fn convention_defaults(db_path: &str) -> Self {
let router_model = "gpt-4.1-mini".to_string();
Self {
provider: Some(ProviderConfig {
runtime: RuntimeProvider::Openai,
}),
openai: Some(OpenAiConfig {
api_key: None,
model: Some(router_model),
base_url: Some("https://api.openai.com/v1".to_string()),
}),
heartbeat_source: default_heartbeat_source(),
prompt_source: default_prompt_source(),
memory: Some(MemoryConfig {
enabled: Some(true),
sqlite_path: Some(db_path.to_string()),
summary_model: Some("gpt-4.1-mini".to_string()),
embedding_model: Some("text-embedding-3-small".to_string()),
rerank_model: Some("gpt-4.1-mini".to_string()),
openai: None,
context_embed_enabled: Some(false),
summary_threshold: None,
retention_days: None,
}),
tools: Some(Value::Object(Map::new())),
brains: None,
}
.apply_security_defaults()
}
pub fn from_store(db_path: &str) -> Result<Self> {
match crate::config_store::load_config(db_path) {
Ok(config) => Ok(config.apply_security_defaults()),
Err(store_err) => {
if let Some(secret) = crate::vault::get_secret("app_config_json")? {
if !secret.trim().is_empty() {
let value: Value = serde_json::from_str(&secret)
.map_err(|e| ButterflyBotError::Config(e.to_string()))?;
let config: Config = serde_json::from_value(value)
.map_err(|e| ButterflyBotError::Config(e.to_string()))?;
return Ok(config.apply_security_defaults());
}
}
Err(store_err)
}
}
}
pub fn resolve_vault(mut self) -> Result<Self> {
if let Some(openai) = &mut self.openai {
if openai.api_key.is_none() {
if let Some(secret) = crate::vault::get_secret_required("openai_api_key")? {
let trimmed = secret.trim();
if !trimmed.is_empty() {
openai.api_key = Some(trimmed.to_string());
}
}
}
}
if let Some(memory) = &mut self.memory {
if let Some(openai) = &mut memory.openai {
if openai.api_key.is_none() {
if let Some(secret) =
crate::vault::get_secret_required("memory_openai_api_key")?
{
let trimmed = secret.trim();
if !trimmed.is_empty() {
openai.api_key = Some(trimmed.to_string());
}
}
}
}
}
Ok(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn convention_defaults_include_solana_rpc_settings() {
let config = Config::convention_defaults(":memory:");
assert_eq!(config.runtime_provider(), RuntimeProvider::Openai);
assert_eq!(
config
.openai
.as_ref()
.and_then(|openai| openai.base_url.as_deref()),
Some("https://api.openai.com/v1")
);
assert_eq!(
config
.memory
.as_ref()
.and_then(|memory| memory.openai.as_ref())
.and_then(|openai| openai.base_url.as_deref()),
None
);
assert_eq!(
config
.memory
.as_ref()
.and_then(|memory| memory.summary_model.as_deref()),
Some("gpt-4.1-mini")
);
assert_eq!(
config
.memory
.as_ref()
.and_then(|memory| memory.rerank_model.as_deref()),
Some("gpt-4.1-mini")
);
assert_eq!(
config
.memory
.as_ref()
.and_then(|memory| memory.embedding_model.as_deref()),
Some("text-embedding-3-small")
);
let tools = config.tools.expect("tools should be initialized");
let rpc = tools
.get("settings")
.and_then(|settings| settings.get("solana"))
.and_then(|solana| solana.get("rpc"))
.expect("tools.settings.solana.rpc should exist");
assert_eq!(
rpc.get("provider").and_then(|v| v.as_str()),
Some("quicknode")
);
assert_eq!(
rpc.get("commitment").and_then(|v| v.as_str()),
Some("confirmed")
);
let simulation = rpc
.get("simulation")
.and_then(|v| v.as_object())
.expect("simulation object should exist");
assert_eq!(
simulation.get("enabled").and_then(|v| v.as_bool()),
Some(true)
);
let send = rpc
.get("send")
.and_then(|v| v.as_object())
.expect("send object should exist");
assert_eq!(
send.get("skip_preflight").and_then(|v| v.as_bool()),
Some(false)
);
let tpm_mode = tools
.get("settings")
.and_then(|settings| settings.get("security"))
.and_then(|security| security.get("tpm_mode"))
.and_then(|v| v.as_str());
assert_eq!(tpm_mode, Some("auto"));
}
#[test]
fn runtime_provider_stays_openai_even_with_local_like_base_url() {
let mut config = Config::convention_defaults(":memory:");
config.provider = Some(ProviderConfig {
runtime: RuntimeProvider::Openai,
});
config.openai = Some(OpenAiConfig {
api_key: None,
model: Some("local-model".to_string()),
base_url: Some("http://127.0.0.1:11434/v1".to_string()),
});
assert_eq!(config.runtime_provider(), RuntimeProvider::Openai);
}
#[test]
fn runtime_provider_stays_openai_when_provider_field_missing() {
let mut config = Config::convention_defaults(":memory:");
config.provider = None;
config.openai = Some(OpenAiConfig {
api_key: None,
model: Some("local-model".to_string()),
base_url: Some("http://127.0.0.1:11434/v1".to_string()),
});
assert_eq!(config.runtime_provider(), RuntimeProvider::Openai);
}
#[test]
fn runtime_provider_infers_openai_for_non_local_base_url_when_missing_provider_field() {
let mut config = Config::convention_defaults(":memory:");
config.provider = None;
config.openai = Some(OpenAiConfig {
api_key: None,
model: Some("gpt-4.1-mini".to_string()),
base_url: Some("https://api.openai.com/v1".to_string()),
});
assert_eq!(config.runtime_provider(), RuntimeProvider::Openai);
}
#[test]
fn apply_security_defaults_adds_tpm_mode_when_missing() {
let raw = json!({
"openai": {
"base_url": "https://api.openai.com/v1",
"model": "gpt-4.1-mini"
},
"tools": {
"settings": {}
}
});
let config: Config = serde_json::from_value(raw).unwrap();
let secured = config.apply_security_defaults();
let tpm_mode = secured
.tools
.and_then(|tools| tools.get("settings").cloned())
.and_then(|settings| settings.get("security").cloned())
.and_then(|security| security.get("tpm_mode").cloned())
.and_then(|value| value.as_str().map(|v| v.to_string()));
assert_eq!(tpm_mode.as_deref(), Some("auto"));
}
#[test]
fn apply_security_defaults_migrates_legacy_ollama_urls() {
let raw = json!({
"openai": {
"base_url": "http://127.0.0.1:11434/v1",
"model": "gpt-5-mini"
},
"memory": {
"enabled": true,
"sqlite_path": ":memory:",
"summary_model": "gpt-5-mini",
"embedding_model": "text-embedding-3-small",
"rerank_model": "gpt-5-mini",
"openai": {
"base_url": "http://localhost:11434/v1"
}
}
});
let config: Config = serde_json::from_value(raw).unwrap();
let secured = config.apply_security_defaults();
assert_eq!(
secured
.openai
.as_ref()
.and_then(|cfg| cfg.base_url.as_deref()),
Some("https://api.openai.com/v1")
);
assert!(secured
.memory
.as_ref()
.and_then(|memory| memory.openai.as_ref())
.is_none());
}
}