use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use crate::providers::index::ProviderIndex;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PerModelOverrides {
#[serde(skip_serializing_if = "Option::is_none")]
pub max_context_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compaction_threshold: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ModelPreset {
#[serde(skip_serializing_if = "String::is_empty", default)]
pub model: String,
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
pub model_tiers: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserEndpoint {
pub name: String,
#[serde(rename = "display_name")]
pub display_name: String,
pub base_url: String,
#[serde(rename = "api_key_env")]
pub api_key_env: String,
#[serde(
rename = "default_model",
skip_serializing_if = "String::is_empty",
default
)]
pub default_model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextWindowPolicy {
#[serde(default = "default_auto_compact")]
pub auto_compact: bool,
#[serde(default = "default_threshold")]
pub threshold: f64,
}
fn default_auto_compact() -> bool {
true
}
fn default_threshold() -> f64 {
0.8
}
impl Default for ContextWindowPolicy {
fn default() -> Self {
Self {
auto_compact: true,
threshold: 0.8,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BridgeSettings {
#[serde(default)]
pub enabled_platforms: Vec<String>,
#[serde(default = "default_listen_addr")]
pub listen_addr: String,
#[serde(default)]
pub default_profile: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub platform_profiles: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub channel_profiles: HashMap<String, String>,
#[serde(default)]
pub default_mode: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub platform_modes: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub channel_modes: HashMap<String, String>,
#[serde(default)]
pub default_project: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub channel_projects: HashMap<String, String>,
#[serde(default)]
pub allowed_users: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub platform_allowed_users: HashMap<String, Vec<String>>,
#[serde(default)]
pub max_concurrent_sessions: usize,
#[serde(default = "default_stream_timeout_secs")]
pub stream_timeout_secs: u64,
}
fn default_stream_timeout_secs() -> u64 {
1800
}
impl BridgeSettings {
pub fn profile_for(&self, platform: &str) -> String {
self.platform_profiles
.get(platform)
.cloned()
.unwrap_or_else(|| self.default_profile.clone())
}
pub fn profile_for_channel(
&self,
platform: &str,
channel_id: &str,
guild_id: Option<&str>,
) -> String {
if let Some(gid) = guild_id {
let full_key = format!("{}:{}:{}", platform, gid, channel_id);
if let Some(p) = self.channel_profiles.get(&full_key) {
return p.clone();
}
let guild_key = format!("{}:{}", platform, gid);
if let Some(p) = self.channel_profiles.get(&guild_key) {
return p.clone();
}
}
let channel_key = format!("{}:{}", platform, channel_id);
self.channel_profiles
.get(&channel_key)
.cloned()
.unwrap_or_else(|| self.profile_for(platform))
}
pub fn mode_for(&self, platform: &str) -> Option<String> {
let mode = self
.platform_modes
.get(platform)
.cloned()
.unwrap_or_else(|| self.default_mode.clone());
if mode.is_empty() { None } else { Some(mode) }
}
pub fn mode_for_channel(
&self,
platform: &str,
channel_id: &str,
guild_id: Option<&str>,
) -> Option<String> {
if let Some(gid) = guild_id {
let full_key = format!("{}:{}:{}", platform, gid, channel_id);
if let Some(mode) = self.channel_modes.get(&full_key)
&& !mode.is_empty()
{
return Some(mode.clone());
}
let guild_key = format!("{}:{}", platform, gid);
if let Some(mode) = self.channel_modes.get(&guild_key)
&& !mode.is_empty()
{
return Some(mode.clone());
}
}
let channel_key = format!("{}:{}", platform, channel_id);
if let Some(mode) = self.channel_modes.get(&channel_key)
&& !mode.is_empty()
{
return Some(mode.clone());
}
self.mode_for(platform)
}
pub fn project_for_channel(
&self,
platform: &str,
channel_id: &str,
guild_id: Option<&str>,
) -> Option<String> {
if let Some(gid) = guild_id {
let full_key = format!("{}:{}:{}", platform, gid, channel_id);
if let Some(p) = self.channel_projects.get(&full_key)
&& !p.is_empty()
{
return Some(p.clone());
}
let guild_key = format!("{}:{}", platform, gid);
if let Some(p) = self.channel_projects.get(&guild_key)
&& !p.is_empty()
{
return Some(p.clone());
}
}
let channel_key = format!("{}:{}", platform, channel_id);
if let Some(p) = self.channel_projects.get(&channel_key)
&& !p.is_empty()
{
return Some(p.clone());
}
if !self.default_project.is_empty() {
Some(self.default_project.clone())
} else {
None
}
}
pub fn allowed_users_for(&self, platform: &str) -> &[String] {
self.platform_allowed_users
.get(platform)
.map(|v| v.as_slice())
.unwrap_or(&self.allowed_users)
}
}
pub fn default_listen_addr() -> String {
"127.0.0.1:3456".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppRegistry {
#[serde(default = "default_version")]
pub version: i32,
#[serde(rename = "provider_overrides", default)]
pub provider_overrides: HashMap<String, ModelPreset>,
#[serde(rename = "openrouter_aliases", default)]
pub openrouter_aliases: HashMap<String, String>,
#[serde(rename = "custom_providers", default)]
pub custom_providers: HashMap<String, UserEndpoint>,
#[serde(default)]
pub compaction: ContextWindowPolicy,
#[serde(rename = "model_settings", default)]
pub model_settings: HashMap<String, PerModelOverrides>,
#[serde(default)]
pub channel: BridgeSettings,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub agents: HashMap<String, crate::domain::agent::AgentConfig>,
}
fn default_version() -> i32 {
1
}
impl Default for AppRegistry {
fn default() -> Self {
AppRegistry {
version: 1,
provider_overrides: HashMap::new(),
openrouter_aliases: HashMap::new(),
custom_providers: HashMap::new(),
compaction: ContextWindowPolicy::default(),
model_settings: HashMap::new(),
channel: BridgeSettings::default(),
agents: HashMap::new(),
}
}
}
impl AppRegistry {
pub fn open(path: impl AsRef<Path>) -> anyhow::Result<Self> {
let path = path.as_ref();
if path.extension().and_then(|e| e.to_str()) == Some("yaml") && !path.exists() {
let json_path = path.with_extension("json");
if json_path.exists() {
let raw = std::fs::read(&json_path)?;
let cfg: Self = serde_json::from_slice(&raw)?;
cfg.write_to(path)?;
let _ = std::fs::remove_file(&json_path);
return Ok(cfg);
}
}
let raw = match std::fs::read(path) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => return Err(e.into()),
};
serde_yaml::from_slice(&raw).map_err(Into::into)
}
pub fn write_to(&self, path: impl AsRef<Path>) -> anyhow::Result<()> {
let p = path.as_ref();
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
let yaml = serde_yaml::to_string(self)?;
super::atomic::write_atomic(&p.to_string_lossy(), yaml.as_bytes(), 0o644)?;
Ok(())
}
pub fn ingest_legacy_secrets(
&mut self,
secrets: &crate::config::vault::SecretVault,
catalog: &ProviderIndex,
) {
let builtin_keys = catalog.builtin_secret_keys();
secrets
.iter()
.filter(|(k, _)| k.ends_with("_API_KEY") || k.starts_with("OPENROUTER_MODEL_"))
.for_each(|(key, value)| {
if let Some(suffix) = key.strip_prefix("OPENROUTER_MODEL_") {
let alias = normalize_openrouter_name(&suffix.to_lowercase().replace('_', "-"));
let val = value.trim();
if !alias.is_empty() && !is_launcher_placeholder(val) && !val.is_empty() {
self.openrouter_aliases
.entry(alias)
.or_insert_with(|| val.to_owned());
}
return;
}
if !key.ends_with("_API_KEY") || builtin_keys.contains(key) {
return;
}
let base_key = format!("CLAUDY_{}_BASE_URL", key);
let base_url = match secrets.get(&base_key) {
Some(v) if !v.is_empty() => v.clone(),
_ => return,
};
let name = key[..key.len() - "_API_KEY".len()]
.to_lowercase()
.replace('_', "-");
if self.custom_providers.contains_key(&name) {
return;
}
self.custom_providers.insert(
name.clone(),
UserEndpoint {
name: name.clone(),
display_name: name,
base_url,
api_key_env: key.clone(),
default_model: String::new(),
},
);
});
}
pub fn is_provider_configured(
&self,
id: &str,
catalog: &ProviderIndex,
secrets: &crate::config::vault::SecretVault,
) -> bool {
let provider = match catalog.get(id) {
Some(p) => p,
None => return false,
};
if provider.auth_mode == "secret" {
return secrets
.get(&provider.key_var)
.cloned()
.or_else(|| std::env::var(&provider.key_var).ok())
.is_some_and(|v| !v.trim().is_empty());
}
self.provider_overrides
.get(id)
.is_some_and(|ov| !ov.model.is_empty() || !ov.model_tiers.is_empty())
}
pub fn is_openrouter_configured(&self, secrets: &crate::config::vault::SecretVault) -> bool {
let has_key = secrets
.get("OPENROUTER_API_KEY")
.cloned()
.or_else(|| std::env::var("OPENROUTER_API_KEY").ok())
.is_some_and(|v| !v.trim().is_empty());
has_key || !self.openrouter_aliases.is_empty()
}
pub fn compact(&mut self, catalog: &ProviderIndex) {
let old = std::mem::take(&mut self.provider_overrides);
self.provider_overrides = old
.into_iter()
.filter_map(|(id, ov)| {
if ov.model.trim().is_empty() && ov.model_tiers.is_empty() {
return None;
}
let provider = catalog.get(&id)?;
let model = resolve_model_choice(provider, &ov.model);
let tiers = prune_empty_tiers(&ov.model_tiers);
if (model.is_empty() || model == provider.default_model) && tiers.is_empty() {
None
} else {
Some((
id,
ModelPreset {
model,
model_tiers: tiers,
},
))
}
})
.collect();
let old_aliases = std::mem::take(&mut self.openrouter_aliases);
self.openrouter_aliases = old_aliases
.into_iter()
.filter_map(|(name, model)| {
let name = normalize_openrouter_name(&name);
let model = model.trim();
if name.is_empty() || model.is_empty() || is_launcher_placeholder(model) {
None
} else {
Some((name, model.to_owned()))
}
})
.collect();
}
pub fn openrouter_names(&self) -> Vec<String> {
let mut keys: Vec<String> = self.openrouter_aliases.keys().cloned().collect();
keys.sort();
keys
}
pub fn custom_provider_names(&self) -> Vec<String> {
let mut keys: Vec<String> = self.custom_providers.keys().cloned().collect();
keys.sort();
keys
}
}
fn resolve_model_choice(
provider: &crate::providers::index::ServiceDescriptor,
raw: &str,
) -> String {
let val = raw.trim();
if val.is_empty() {
return String::new();
}
val.parse::<usize>()
.ok()
.and_then(|idx| provider.model_choices.get(idx.wrapping_sub(1)))
.map(|c| c.id.clone())
.unwrap_or_else(|| val.to_owned())
}
fn prune_empty_tiers(tiers: &HashMap<String, String>) -> HashMap<String, String> {
const RECOGNIZED: &[&str] = &["opus", "sonnet", "haiku", "small"];
tiers
.iter()
.filter(|(k, v)| RECOGNIZED.contains(&k.as_str()) && !v.trim().is_empty())
.map(|(k, v)| (k.clone(), v.trim().to_owned()))
.collect()
}
pub fn normalize_openrouter_name(name: &str) -> String {
name.trim()
.to_lowercase()
.trim_start_matches("claudy-or-")
.trim_matches('-')
.to_owned()
}
pub fn is_launcher_placeholder(value: &str) -> bool {
let lower = value.trim().to_lowercase();
lower.starts_with("claudy-") || lower.starts_with("claudy ")
}
pub fn open_registry(path: &str) -> anyhow::Result<AppRegistry> {
AppRegistry::open(path)
}
pub fn write_registry(path: &str, cfg: &AppRegistry) -> anyhow::Result<()> {
cfg.write_to(path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::index as providers;
fn load_catalog() -> ProviderIndex {
providers::load_index().expect("catalog should load")
}
#[test]
fn test_compact_repairs_legacy_overrides_and_aliases() {
let catalog = load_catalog();
let mut cfg = AppRegistry {
version: 1,
provider_overrides: HashMap::from([(
"zai".to_string(),
ModelPreset {
model: "1".to_string(),
model_tiers: HashMap::new(),
},
)]),
openrouter_aliases: HashMap::from([
(
"claudy-or-kimi-k25".to_string(),
"claudy-or-kimi-k25".to_string(),
),
("kimi-k25".to_string(), "moonshotai/kimi-k2.5".to_string()),
]),
..Default::default()
};
cfg.compact(&catalog);
assert!(!cfg.provider_overrides.contains_key("zai"));
assert_eq!(cfg.openrouter_aliases.len(), 1);
assert_eq!(
cfg.openrouter_aliases.get("kimi-k25").map(|s| s.as_str()),
Some("moonshotai/kimi-k2.5")
);
}
#[test]
fn test_project_for_channel_fallback_order() {
let cfg = BridgeSettings {
default_project: "/default".to_string(),
channel_projects: HashMap::from([
("discord:guild1".to_string(), "/guild1".to_string()),
("discord:guild1:ch1".to_string(), "/guild1-ch1".to_string()),
("discord:ch2".to_string(), "/ch2".to_string()),
]),
..Default::default()
};
assert_eq!(
cfg.project_for_channel("discord", "ch1", Some("guild1")),
Some("/guild1-ch1".to_string())
);
assert_eq!(
cfg.project_for_channel("discord", "ch_other", Some("guild1")),
Some("/guild1".to_string())
);
assert_eq!(
cfg.project_for_channel("discord", "ch2", None),
Some("/ch2".to_string())
);
assert_eq!(
cfg.project_for_channel("discord", "ch_unknown", None),
Some("/default".to_string())
);
}
#[test]
fn test_project_for_channel_no_default() {
let cfg = BridgeSettings {
channel_projects: HashMap::from([("slack:ch1".to_string(), "/project".to_string())]),
..Default::default()
};
assert_eq!(
cfg.project_for_channel("slack", "ch1", None),
Some("/project".to_string())
);
assert_eq!(cfg.project_for_channel("slack", "ch_unknown", None), None);
}
#[test]
fn test_project_for_channel_empty_value_skipped() {
let cfg = BridgeSettings {
default_project: "/default".to_string(),
channel_projects: HashMap::from([
("discord:ch1".to_string(), "".to_string()),
("discord:guild1:ch2".to_string(), "".to_string()),
]),
..Default::default()
};
assert_eq!(
cfg.project_for_channel("discord", "ch1", None),
Some("/default".to_string())
);
assert_eq!(
cfg.project_for_channel("discord", "ch2", Some("guild1")),
Some("/default".to_string())
);
}
#[test]
fn test_bridge_settings_project_serialization() {
let cfg = BridgeSettings {
default_project: "/home/user/project".to_string(),
channel_projects: HashMap::from([
(
"discord:123".to_string(),
"/home/user/discord-proj".to_string(),
),
(
"slack:T123:C456".to_string(),
"/home/user/slack-proj".to_string(),
),
]),
..Default::default()
};
let yaml = serde_yaml::to_string(&cfg).unwrap();
assert!(yaml.contains("default_project: /home/user/project"));
assert!(yaml.contains("discord:123"));
assert!(yaml.contains("slack:T123:C456"));
let deserialized: BridgeSettings = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(deserialized.default_project, "/home/user/project");
assert_eq!(deserialized.channel_projects.len(), 2);
}
}