feat: implement config loading and OS keyring credential storage
This commit is contained in:
parent
271aeac23c
commit
e67a48b81b
2 changed files with 242 additions and 0 deletions
240
src/config.rs
Normal file
240
src/config.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
use crate::budget::CreditTier;
|
||||||
|
use crate::persona::{Persona, PersonaRegistry};
|
||||||
|
use crate::pipeline::PipelineConfig;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const KEYRING_SERVICE: &str = "brokecrew";
|
||||||
|
const KEYRING_USER: &str = "openrouter_api_key";
|
||||||
|
const DEFAULT_PERSONAS_TOML: &str = include_str!("../config/personas.example.toml");
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AppConfigFile {
|
||||||
|
#[serde(default = "default_persona_str")]
|
||||||
|
pub default_persona: String,
|
||||||
|
#[serde(default = "default_router_model")]
|
||||||
|
pub router_model: String,
|
||||||
|
#[serde(default = "default_complexity_threshold")]
|
||||||
|
pub complexity_threshold: u8,
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_stealth: bool,
|
||||||
|
#[serde(default = "default_credit_tier")]
|
||||||
|
pub credit_tier: CreditTier,
|
||||||
|
#[serde(default = "default_base_url")]
|
||||||
|
pub openrouter_base_url: String,
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_persona_str() -> String {
|
||||||
|
"general".to_string()
|
||||||
|
}
|
||||||
|
fn default_router_model() -> String {
|
||||||
|
"meta-llama/llama-3.2-3b-instruct:free".to_string()
|
||||||
|
}
|
||||||
|
fn default_complexity_threshold() -> u8 {
|
||||||
|
7
|
||||||
|
}
|
||||||
|
fn default_credit_tier() -> CreditTier {
|
||||||
|
CreditTier::LowBalance
|
||||||
|
}
|
||||||
|
fn default_base_url() -> String {
|
||||||
|
"https://openrouter.ai/api/v1".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppConfigFile {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
default_persona: default_persona_str(),
|
||||||
|
router_model: default_router_model(),
|
||||||
|
complexity_threshold: default_complexity_threshold(),
|
||||||
|
allow_stealth: false,
|
||||||
|
credit_tier: default_credit_tier(),
|
||||||
|
openrouter_base_url: default_base_url(),
|
||||||
|
api_key: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
pub struct PersonasConfigFile {
|
||||||
|
#[serde(rename = "persona", default)]
|
||||||
|
pub personas: Vec<Persona>,
|
||||||
|
#[serde(rename = "pipeline", default)]
|
||||||
|
pub pipelines: Vec<PipelineConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub default_persona: String,
|
||||||
|
pub router_model: String,
|
||||||
|
pub complexity_threshold: u8,
|
||||||
|
pub allow_stealth: bool,
|
||||||
|
pub credit_tier: CreditTier,
|
||||||
|
pub openrouter_base_url: String,
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
pub persona_registry: PersonaRegistry,
|
||||||
|
pub pipelines: Vec<PipelineConfig>,
|
||||||
|
pub config_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn default_config_dir() -> PathBuf {
|
||||||
|
dirs::config_dir()
|
||||||
|
.map(|p| p.join("brokecrew"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from(".brokecrew"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from_paths(
|
||||||
|
config_path: Option<&Path>,
|
||||||
|
personas_path: Option<&Path>,
|
||||||
|
cli_api_key: Option<String>,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let config_dir = Self::default_config_dir();
|
||||||
|
|
||||||
|
// 1. Load config.toml
|
||||||
|
let config_file_path = config_path
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| config_dir.join("config.toml"));
|
||||||
|
|
||||||
|
let app_file = if config_file_path.exists() {
|
||||||
|
let data = fs::read_to_string(&config_file_path)
|
||||||
|
.map_err(|e| format!("Failed to read {}: {e}", config_file_path.display()))?;
|
||||||
|
toml::from_str::<AppConfigFile>(&data)
|
||||||
|
.map_err(|e| format!("Failed to parse {}: {e}", config_file_path.display()))?
|
||||||
|
} else {
|
||||||
|
AppConfigFile::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Load personas.toml
|
||||||
|
let personas_file_path = personas_path
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| config_dir.join("personas.toml"));
|
||||||
|
|
||||||
|
let personas_config = if personas_file_path.exists() {
|
||||||
|
let data = fs::read_to_string(&personas_file_path)
|
||||||
|
.map_err(|e| format!("Failed to read {}: {e}", personas_file_path.display()))?;
|
||||||
|
toml::from_str::<PersonasConfigFile>(&data)
|
||||||
|
.map_err(|e| format!("Failed to parse {}: {e}", personas_file_path.display()))?
|
||||||
|
} else {
|
||||||
|
// Fallback to built-in personas.example.toml content
|
||||||
|
toml::from_str::<PersonasConfigFile>(DEFAULT_PERSONAS_TOML)
|
||||||
|
.map_err(|e| format!("Failed to parse bundled personas: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Resolve API Key: CLI -> Env -> Keyring -> Config file
|
||||||
|
let api_key = Self::resolve_api_key(cli_api_key, app_file.api_key, true);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
default_persona: app_file.default_persona,
|
||||||
|
router_model: app_file.router_model,
|
||||||
|
complexity_threshold: app_file.complexity_threshold,
|
||||||
|
allow_stealth: app_file.allow_stealth,
|
||||||
|
credit_tier: app_file.credit_tier,
|
||||||
|
openrouter_base_url: app_file.openrouter_base_url,
|
||||||
|
api_key,
|
||||||
|
persona_registry: PersonaRegistry::new(personas_config.personas),
|
||||||
|
pipelines: personas_config.pipelines,
|
||||||
|
config_dir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the API key using precedence:
|
||||||
|
/// 1. CLI argument
|
||||||
|
/// 2. `OPENROUTER_API_KEY` environment variable
|
||||||
|
/// 3. OS Keyring
|
||||||
|
/// 4. Config file
|
||||||
|
pub fn resolve_api_key(
|
||||||
|
cli_key: Option<String>,
|
||||||
|
config_file_key: Option<String>,
|
||||||
|
use_keyring: bool,
|
||||||
|
) -> Option<String> {
|
||||||
|
if let Some(key) = cli_key.filter(|k| !k.trim().is_empty()) {
|
||||||
|
return Some(key.trim().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(env_key) = std::env::var("OPENROUTER_API_KEY") {
|
||||||
|
if !env_key.trim().is_empty() {
|
||||||
|
return Some(env_key.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if use_keyring {
|
||||||
|
if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER) {
|
||||||
|
if let Ok(secret) = entry.get_password() {
|
||||||
|
if !secret.trim().is_empty() {
|
||||||
|
return Some(secret.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(key) = config_file_key.filter(|k| !k.trim().is_empty()) {
|
||||||
|
return Some(key.trim().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves API key securely to the OS credential store
|
||||||
|
pub fn save_api_key_to_keyring(api_key: &str) -> Result<(), String> {
|
||||||
|
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)
|
||||||
|
.map_err(|e| format!("Failed to access OS keyring: {e}"))?;
|
||||||
|
entry
|
||||||
|
.set_password(api_key.trim())
|
||||||
|
.map_err(|e| format!("Failed to store secret in OS keyring: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_api_key_priority() {
|
||||||
|
// 1. CLI flag has highest priority
|
||||||
|
let key = AppConfig::resolve_api_key(
|
||||||
|
Some("cli-key-123".into()),
|
||||||
|
Some("file-key-456".into()),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(key, Some("cli-key-123".into()));
|
||||||
|
|
||||||
|
// 2. Fallback to config file when no env or cli
|
||||||
|
let key = AppConfig::resolve_api_key(None, Some("file-key-456".into()), false);
|
||||||
|
assert_eq!(key, Some("file-key-456".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_personas_config_file_parse() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[persona]]
|
||||||
|
name = "tester"
|
||||||
|
system_prompt = "You test code"
|
||||||
|
trigger_words = ["test", "verify"]
|
||||||
|
|
||||||
|
[[persona.candidates]]
|
||||||
|
id = "model-a:free"
|
||||||
|
tier = "free"
|
||||||
|
is_stealth = false
|
||||||
|
|
||||||
|
[[pipeline]]
|
||||||
|
name = "test-pipe"
|
||||||
|
steps = [
|
||||||
|
{ persona = "tester" }
|
||||||
|
]
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let parsed: PersonasConfigFile = toml::from_str(toml_str).unwrap();
|
||||||
|
assert_eq!(parsed.personas.len(), 1);
|
||||||
|
assert_eq!(parsed.personas[0].name, "tester");
|
||||||
|
assert_eq!(parsed.pipelines.len(), 1);
|
||||||
|
assert_eq!(parsed.pipelines[0].name, "test-pipe");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bundled_personas_valid() {
|
||||||
|
let parsed: PersonasConfigFile = toml::from_str(DEFAULT_PERSONAS_TOML).unwrap();
|
||||||
|
assert!(parsed.personas.len() >= 3);
|
||||||
|
assert!(parsed.pipelines.len() >= 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
pub mod budget;
|
pub mod budget;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod config;
|
||||||
pub mod persona;
|
pub mod persona;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
|
@ -9,6 +10,7 @@ pub mod router;
|
||||||
|
|
||||||
pub use budget::{BudgetExceeded, BudgetStatus, BudgetTracker, CreditTier};
|
pub use budget::{BudgetExceeded, BudgetStatus, BudgetTracker, CreditTier};
|
||||||
pub use client::{ClientError, ModelClient, OpenRouterClient};
|
pub use client::{ClientError, ModelClient, OpenRouterClient};
|
||||||
|
pub use config::{AppConfig, AppConfigFile, PersonasConfigFile};
|
||||||
pub use persona::{DispatchResult, ModelAttempt, ModelCandidate, Persona, PersonaRegistry, Tier};
|
pub use persona::{DispatchResult, ModelAttempt, ModelCandidate, Persona, PersonaRegistry, Tier};
|
||||||
pub use pipeline::{PipelineConfig, PipelineEvent, PipelineExecutor, PipelineStep};
|
pub use pipeline::{PipelineConfig, PipelineEvent, PipelineExecutor, PipelineStep};
|
||||||
pub use registry::{ModelInfo, ModelPricing, ModelRegistry};
|
pub use registry::{ModelInfo, ModelPricing, ModelRegistry};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue