#![allow(clippy::struct_excessive_bools)]
use std::env;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UpstreamProvider {
#[default]
Anthropic,
Gonka,
}
impl UpstreamProvider {
#[must_use]
pub fn from_str_opt(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"anthropic" | "claude" => Some(Self::Anthropic),
"gonka" => Some(Self::Gonka),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiFormat {
Anthropic,
Bedrock,
Vertex,
}
impl ApiFormat {
#[must_use]
pub fn from_str_opt(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"anthropic" | "messages" => Some(Self::Anthropic),
"bedrock" | "invoke" => Some(Self::Bedrock),
"vertex" | "rawpredict" => Some(Self::Vertex),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RoutingMode {
#[default]
Direct,
Cli,
Hybrid,
}
impl RoutingMode {
#[must_use]
pub fn from_str_opt(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"direct" => Some(Self::Direct),
"cli" | "subprocess" => Some(Self::Cli),
"hybrid" | "auto" => Some(Self::Hybrid),
_ => None,
}
}
}
impl FromStr for RoutingMode {
type Err = ConfigError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_str_opt(s).ok_or(ConfigError::InvalidRoutingMode)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StoragePolicy {
Memory,
Text,
Binary,
#[default]
Both,
}
impl StoragePolicy {
#[must_use]
pub fn from_str_opt(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"memory" | "mem" | "none" => Some(Self::Memory),
"text" | "lino" => Some(Self::Text),
"binary" | "bin" | "link-cli" | "linkcli" | "clink" => Some(Self::Binary),
"both" | "dual" => Some(Self::Both),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub listen_addr: SocketAddr,
pub token_secret: String,
pub claude_code_home: String,
pub upstream_base_url: String,
pub verbose: bool,
pub api_format: Option<ApiFormat>,
pub routing_mode: RoutingMode,
pub storage_policy: StoragePolicy,
pub data_dir: PathBuf,
pub claude_cli_bin: Option<PathBuf>,
pub upstream_provider: UpstreamProvider,
pub gonka_private_key: Option<String>,
pub gonka_source_url: String,
pub gonka_model: String,
pub activitypub_actor_base_url: String,
pub activitypub_public_key_pem: String,
pub enable_openai_api: bool,
pub enable_anthropic_api: bool,
pub enable_metrics: bool,
pub additional_account_dirs: Vec<PathBuf>,
pub experimental_compatibility: bool,
pub admin_key: Option<String>,
pub mpp: crate::mpp::MppConfig,
}
impl Config {
pub fn from_env() -> Result<Self, ConfigError> {
let port = env::var("ROUTER_PORT").unwrap_or_else(|_| "8080".to_string());
let host = env::var("ROUTER_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
let token_secret = env::var("TOKEN_SECRET").ok();
let claude_code_home = env::var("CLAUDE_CODE_HOME").unwrap_or_else(|_| {
let home = env::var("HOME").unwrap_or_else(|_| "/root".to_string());
format!("{home}/.claude")
});
let upstream_base_url = env::var("UPSTREAM_BASE_URL")
.unwrap_or_else(|_| "https://api.anthropic.com".to_string());
let verbose = env::var("VERBOSE").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
let api_format = env::var("UPSTREAM_API_FORMAT")
.ok()
.and_then(|s| ApiFormat::from_str_opt(&s));
let routing_mode = env::var("ROUTING_MODE")
.ok()
.and_then(|s| RoutingMode::from_str_opt(&s))
.unwrap_or_default();
let storage_policy = env::var("STORAGE_POLICY")
.ok()
.and_then(|s| StoragePolicy::from_str_opt(&s))
.unwrap_or_default();
let data_dir = env::var("DATA_DIR").map_or_else(|_| default_data_dir(), PathBuf::from);
let claude_cli_bin = env::var("CLAUDE_CLI_BIN").ok().map(PathBuf::from);
let upstream_provider = env::var("UPSTREAM_PROVIDER")
.ok()
.and_then(|s| UpstreamProvider::from_str_opt(&s))
.unwrap_or_default();
let gonka_private_key = env::var("GONKA_PRIVATE_KEY").ok().filter(|s| !s.is_empty());
let gonka_source_url =
env::var("GONKA_SOURCE_URL").unwrap_or_else(|_| default_gonka_source_url());
let gonka_model = env::var("GONKA_MODEL").unwrap_or_else(|_| default_gonka_model());
let activitypub_actor_base_url = env::var("ACTIVITYPUB_ACTOR_BASE_URL")
.unwrap_or_else(|_| format!("http://{host}:{port}"));
let activitypub_public_key_pem = env::var("ACTIVITYPUB_PUBLIC_KEY_PEM")
.unwrap_or_else(|_| default_activitypub_public_key_pem());
let enable_openai_api = env::var("ENABLE_OPENAI_API").map_or(true, |v| {
!matches!(v.as_str(), "0" | "false" | "FALSE" | "off")
});
let enable_anthropic_api = env::var("ENABLE_ANTHROPIC_API").map_or(true, |v| {
!matches!(v.as_str(), "0" | "false" | "FALSE" | "off")
});
let enable_metrics = env::var("ENABLE_METRICS").map_or(true, |v| {
!matches!(v.as_str(), "0" | "false" | "FALSE" | "off")
});
let additional_account_dirs = env::var("ADDITIONAL_ACCOUNT_DIRS")
.ok()
.map(|raw| {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect()
})
.unwrap_or_default();
let experimental_compatibility = env::var("EXPERIMENTAL_COMPATIBILITY")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
let admin_key = env::var("TOKEN_ADMIN_KEY").ok().filter(|s| !s.is_empty());
let mpp = crate::mpp::MppConfig {
enabled: env::var("MPP_ENABLE")
.is_ok_and(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "on" | "ON")),
amount: env::var("MPP_AMOUNT").unwrap_or_else(|_| "0.00".to_string()),
currency: env::var("MPP_CURRENCY").unwrap_or_else(|_| "USD".to_string()),
recipient: env::var("MPP_RECIPIENT").unwrap_or_default(),
method: env::var("MPP_METHOD").ok().filter(|s| !s.is_empty()),
};
Self::build(BuildArgs {
host: &host,
port: &port,
token_secret: token_secret.as_deref(),
claude_code_home: &claude_code_home,
upstream_base_url: &upstream_base_url,
verbose,
api_format,
routing_mode,
storage_policy,
data_dir,
claude_cli_bin,
upstream_provider,
gonka_private_key,
gonka_source_url,
gonka_model,
activitypub_actor_base_url,
activitypub_public_key_pem,
enable_openai_api,
enable_anthropic_api,
enable_metrics,
additional_account_dirs,
experimental_compatibility,
admin_key,
mpp,
})
}
pub fn build(args: BuildArgs<'_>) -> Result<Self, ConfigError> {
let port: u16 = args.port.parse().map_err(|_| ConfigError::InvalidPort)?;
let listen_addr: SocketAddr = format!("{}:{}", args.host, port)
.parse()
.map_err(|_| ConfigError::InvalidAddress)?;
let token_secret = args
.token_secret
.filter(|s| !s.is_empty())
.ok_or(ConfigError::MissingTokenSecret)?
.to_string();
if args.upstream_provider == UpstreamProvider::Gonka
&& !matches!(args.gonka_private_key.as_deref(), Some(s) if !s.is_empty())
{
return Err(ConfigError::MissingGonkaPrivateKey);
}
Ok(Self {
listen_addr,
token_secret,
claude_code_home: args.claude_code_home.to_string(),
upstream_base_url: args.upstream_base_url.to_string(),
verbose: args.verbose,
api_format: args.api_format,
routing_mode: args.routing_mode,
storage_policy: args.storage_policy,
data_dir: args.data_dir,
claude_cli_bin: args.claude_cli_bin,
upstream_provider: args.upstream_provider,
gonka_private_key: args.gonka_private_key.filter(|s| !s.is_empty()),
gonka_source_url: args.gonka_source_url.trim_end_matches('/').to_string(),
gonka_model: args.gonka_model,
activitypub_actor_base_url: args
.activitypub_actor_base_url
.trim_end_matches('/')
.to_string(),
activitypub_public_key_pem: args.activitypub_public_key_pem,
enable_openai_api: args.enable_openai_api,
enable_anthropic_api: args.enable_anthropic_api,
enable_metrics: args.enable_metrics,
additional_account_dirs: args.additional_account_dirs,
experimental_compatibility: args.experimental_compatibility,
admin_key: args.admin_key,
mpp: args.mpp,
})
}
}
pub struct BuildArgs<'a> {
pub host: &'a str,
pub port: &'a str,
pub token_secret: Option<&'a str>,
pub claude_code_home: &'a str,
pub upstream_base_url: &'a str,
pub verbose: bool,
pub api_format: Option<ApiFormat>,
pub routing_mode: RoutingMode,
pub storage_policy: StoragePolicy,
pub data_dir: PathBuf,
pub claude_cli_bin: Option<PathBuf>,
pub upstream_provider: UpstreamProvider,
pub gonka_private_key: Option<String>,
pub gonka_source_url: String,
pub gonka_model: String,
pub activitypub_actor_base_url: String,
pub activitypub_public_key_pem: String,
pub enable_openai_api: bool,
pub enable_anthropic_api: bool,
pub enable_metrics: bool,
pub additional_account_dirs: Vec<PathBuf>,
pub experimental_compatibility: bool,
pub admin_key: Option<String>,
pub mpp: crate::mpp::MppConfig,
}
#[must_use]
pub fn default_mpp_config() -> crate::mpp::MppConfig {
crate::mpp::MppConfig {
enabled: false,
amount: "0.00".to_string(),
currency: "USD".to_string(),
recipient: String::new(),
method: None,
}
}
#[must_use]
pub fn default_data_dir() -> PathBuf {
if let Ok(d) = env::var("DATA_DIR") {
return PathBuf::from(d);
}
let home = env::var("HOME").unwrap_or_else(|_| "/var/lib/link-assistant-router".to_string());
PathBuf::from(home).join(".link-assistant-router")
}
#[must_use]
pub fn default_activitypub_public_key_pem() -> String {
"-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA0000000000000000000000000000000000000000000=\n-----END PUBLIC KEY-----".to_string()
}
#[must_use]
pub fn default_gonka_source_url() -> String {
"https://node4.gonka.ai".to_string()
}
#[must_use]
pub fn default_gonka_model() -> String {
"Qwen/Qwen3-235B-A22B-Instruct-2507-FP8".to_string()
}
#[derive(Debug)]
pub enum ConfigError {
InvalidPort,
InvalidAddress,
MissingTokenSecret,
InvalidRoutingMode,
MissingGonkaPrivateKey,
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidPort => write!(f, "ROUTER_PORT must be a valid port number (0-65535)"),
Self::InvalidAddress => write!(f, "Could not parse listen address"),
Self::MissingTokenSecret => {
write!(f, "TOKEN_SECRET environment variable is required")
}
Self::InvalidRoutingMode => {
write!(f, "ROUTING_MODE must be one of: direct, cli, hybrid")
}
Self::MissingGonkaPrivateKey => write!(
f,
"Gonka provider requires GONKA_PRIVATE_KEY. Make sure your Gonka account is activated for inference, funded, and has a published on-chain public key."
),
}
}
}
impl std::error::Error for ConfigError {}
#[cfg(test)]
mod tests {
use super::*;
fn build_default(secret: Option<&str>) -> Result<Config, ConfigError> {
Config::build(BuildArgs {
host: "0.0.0.0",
port: "8080",
token_secret: secret,
claude_code_home: "/tmp/claude",
upstream_base_url: "https://api.anthropic.com",
verbose: false,
api_format: None,
routing_mode: RoutingMode::Direct,
storage_policy: StoragePolicy::Memory,
data_dir: PathBuf::from("/tmp/test-data"),
claude_cli_bin: None,
upstream_provider: UpstreamProvider::Anthropic,
gonka_private_key: None,
gonka_source_url: default_gonka_source_url(),
gonka_model: default_gonka_model(),
activitypub_actor_base_url: "https://router.example".into(),
activitypub_public_key_pem: default_activitypub_public_key_pem(),
enable_openai_api: true,
enable_anthropic_api: true,
enable_metrics: true,
additional_account_dirs: vec![],
experimental_compatibility: false,
admin_key: None,
mpp: default_mpp_config(),
})
}
#[test]
fn test_config_missing_token_secret() {
let result = build_default(None);
assert!(result.is_err());
}
#[test]
fn test_config_empty_token_secret() {
let result = build_default(Some(""));
assert!(result.is_err());
}
#[test]
fn test_config_with_valid_values() {
let config = build_default(Some("test-secret-key")).expect("Config should build");
assert_eq!(config.listen_addr.port(), 8080);
assert_eq!(config.token_secret, "test-secret-key");
assert_eq!(config.claude_code_home, "/tmp/claude");
assert_eq!(config.upstream_base_url, "https://api.anthropic.com");
assert_eq!(config.upstream_provider, UpstreamProvider::Anthropic);
assert!(!config.verbose);
assert_eq!(config.routing_mode, RoutingMode::Direct);
}
#[test]
fn default_provider_is_anthropic() {
let config = build_default(Some("secret")).expect("should build");
assert_eq!(config.upstream_provider, UpstreamProvider::Anthropic);
}
#[test]
fn gonka_provider_requires_private_key() {
let result = Config::build(gonka_args(None));
assert!(matches!(result, Err(ConfigError::MissingGonkaPrivateKey)));
}
#[test]
fn gonka_provider_builds_with_private_key_and_defaults() {
let config = Config::build(gonka_args(Some("gonka-private-key")))
.expect("gonka config should build");
assert_eq!(config.upstream_provider, UpstreamProvider::Gonka);
assert_eq!(config.gonka_source_url, default_gonka_source_url());
assert_eq!(config.gonka_model, default_gonka_model());
assert_eq!(
config.gonka_private_key.as_deref(),
Some("gonka-private-key")
);
}
fn gonka_args(private_key: Option<&str>) -> BuildArgs<'static> {
BuildArgs {
host: "0.0.0.0",
port: "8080",
token_secret: Some("secret"),
claude_code_home: "/tmp/claude",
upstream_base_url: "https://api.anthropic.com",
verbose: false,
api_format: None,
routing_mode: RoutingMode::Direct,
storage_policy: StoragePolicy::Memory,
data_dir: PathBuf::from("/tmp/test-data"),
claude_cli_bin: None,
upstream_provider: UpstreamProvider::Gonka,
gonka_private_key: private_key.map(str::to_string),
gonka_source_url: default_gonka_source_url(),
gonka_model: default_gonka_model(),
activitypub_actor_base_url: "https://router.example".into(),
activitypub_public_key_pem: default_activitypub_public_key_pem(),
enable_openai_api: true,
enable_anthropic_api: true,
enable_metrics: true,
additional_account_dirs: vec![],
experimental_compatibility: false,
admin_key: None,
mpp: default_mpp_config(),
}
}
#[test]
fn test_config_invalid_port() {
let result = Config::build(BuildArgs {
host: "0.0.0.0",
port: "not-a-number",
token_secret: Some("secret"),
claude_code_home: "/tmp/claude",
upstream_base_url: "https://api.anthropic.com",
verbose: false,
api_format: None,
routing_mode: RoutingMode::Direct,
storage_policy: StoragePolicy::Memory,
data_dir: PathBuf::from("/tmp/test-data"),
claude_cli_bin: None,
upstream_provider: UpstreamProvider::Anthropic,
gonka_private_key: None,
gonka_source_url: default_gonka_source_url(),
gonka_model: default_gonka_model(),
activitypub_actor_base_url: "https://router.example".into(),
activitypub_public_key_pem: default_activitypub_public_key_pem(),
enable_openai_api: true,
enable_anthropic_api: true,
enable_metrics: true,
additional_account_dirs: vec![],
experimental_compatibility: false,
admin_key: None,
mpp: default_mpp_config(),
});
assert!(result.is_err());
}
#[test]
fn test_config_default_port() {
let config = build_default(Some("secret")).expect("should build");
assert_eq!(config.listen_addr.port(), 8080);
}
#[test]
fn test_api_format_parsing() {
assert_eq!(
ApiFormat::from_str_opt("anthropic"),
Some(ApiFormat::Anthropic)
);
assert_eq!(
ApiFormat::from_str_opt("messages"),
Some(ApiFormat::Anthropic)
);
assert_eq!(ApiFormat::from_str_opt("bedrock"), Some(ApiFormat::Bedrock));
assert_eq!(ApiFormat::from_str_opt("invoke"), Some(ApiFormat::Bedrock));
assert_eq!(ApiFormat::from_str_opt("vertex"), Some(ApiFormat::Vertex));
assert_eq!(
ApiFormat::from_str_opt("rawpredict"),
Some(ApiFormat::Vertex)
);
assert_eq!(
ApiFormat::from_str_opt("ANTHROPIC"),
Some(ApiFormat::Anthropic)
);
assert!(ApiFormat::from_str_opt("unknown").is_none());
}
#[test]
fn test_routing_mode_parsing() {
assert_eq!(
RoutingMode::from_str_opt("direct"),
Some(RoutingMode::Direct)
);
assert_eq!(RoutingMode::from_str_opt("cli"), Some(RoutingMode::Cli));
assert_eq!(
RoutingMode::from_str_opt("hybrid"),
Some(RoutingMode::Hybrid)
);
assert_eq!(RoutingMode::from_str_opt("auto"), Some(RoutingMode::Hybrid));
assert_eq!(
RoutingMode::from_str_opt("subprocess"),
Some(RoutingMode::Cli)
);
assert!(RoutingMode::from_str_opt("nope").is_none());
}
#[test]
fn test_storage_policy_parsing() {
assert_eq!(
StoragePolicy::from_str_opt("memory"),
Some(StoragePolicy::Memory)
);
assert_eq!(
StoragePolicy::from_str_opt("text"),
Some(StoragePolicy::Text)
);
assert_eq!(
StoragePolicy::from_str_opt("binary"),
Some(StoragePolicy::Binary)
);
assert_eq!(
StoragePolicy::from_str_opt("both"),
Some(StoragePolicy::Both)
);
assert!(StoragePolicy::from_str_opt("nope").is_none());
}
#[test]
fn test_verbose_default_false() {
let config = build_default(Some("secret")).expect("should build");
assert!(!config.verbose);
}
}