#![allow(clippy::struct_excessive_bools)]
use std::path::PathBuf;
use std::time::Duration;
use clap::Subcommand;
use lino_arguments::Parser as LinoParser;
use crate::config::{
default_activitypub_public_key_pem, default_data_dir, ApiFormat, BuildArgs, Config,
ConfigError, RoutingMode, StoragePolicy, UpstreamProvider,
};
#[derive(Debug, LinoParser)]
#[command(
name = "link-assistant-router",
about = "Claude MAX OAuth proxy and token gateway for Anthropic APIs",
version
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
#[arg(long, env = "ROUTER_HOST", default_value = "0.0.0.0", global = true)]
pub host: String,
#[arg(long, env = "ROUTER_PORT", default_value = "8080", global = true)]
pub port: u16,
#[arg(long, env = "VERBOSE", global = true)]
pub verbose: bool,
#[arg(long, env = "TOKEN_SECRET", global = true)]
pub token_secret: Option<String>,
#[arg(long, env = "CLAUDE_CODE_HOME", global = true)]
pub claude_code_home: Option<String>,
#[arg(
long,
env = "UPSTREAM_BASE_URL",
default_value = "https://api.anthropic.com",
global = true
)]
pub upstream_base_url: String,
#[arg(long, env = "UPSTREAM_API_FORMAT", global = true)]
pub api_format: Option<String>,
#[arg(long, env = "ROUTING_MODE", default_value = "direct", global = true)]
pub routing_mode: String,
#[arg(long, env = "STORAGE_POLICY", default_value = "both", global = true)]
pub storage_policy: String,
#[arg(long, env = "DATA_DIR", global = true)]
pub data_dir: Option<PathBuf>,
#[arg(long, env = "CLAUDE_CLI_BIN", global = true)]
pub claude_cli_bin: Option<PathBuf>,
#[arg(
long,
env = "UPSTREAM_PROVIDER",
default_value = "anthropic",
global = true
)]
pub upstream_provider: String,
#[arg(long, env = "GONKA_PRIVATE_KEY", global = true)]
pub gonka_private_key: Option<String>,
#[arg(
long,
env = "GONKA_SOURCE_URL",
default_value = "https://node4.gonka.ai",
global = true
)]
pub gonka_source_url: String,
#[arg(
long,
env = "GONKA_MODEL",
default_value = "Qwen/Qwen3-235B-A22B-Instruct-2507-FP8",
global = true
)]
pub gonka_model: String,
#[arg(long, env = "CRATER_FORGEFED_INBOX", global = true)]
pub crater_forgefed_inbox: Option<String>,
#[arg(long, env = "CRATER_FORGEFED_ACTOR", global = true)]
pub crater_forgefed_actor: Option<String>,
#[arg(long, env = "CRATER_FORGEFED_TARGET", global = true)]
pub crater_forgefed_target: Option<String>,
#[arg(
long,
env = "CRATER_POLL_INTERVAL_MS",
default_value_t = 1000,
global = true
)]
pub crater_poll_interval_ms: u64,
#[arg(
long,
env = "CRATER_POLL_TIMEOUT_SECS",
default_value_t = 120,
global = true
)]
pub crater_poll_timeout_secs: u64,
#[arg(
long,
env = "OPENAI_COMPATIBLE_PROVIDER_NAME",
default_value = "litellm",
global = true
)]
pub openai_compatible_provider_name: String,
#[arg(
long,
env = "OPENAI_COMPATIBLE_BASE_URL",
default_value = "http://localhost:4000/v1",
global = true
)]
pub openai_compatible_base_url: String,
#[arg(long, env = "OPENAI_COMPATIBLE_API_KEY", global = true)]
pub openai_compatible_api_key: Option<String>,
#[arg(long, env = "OPENAI_COMPATIBLE_API_KEY_ENV", global = true)]
pub openai_compatible_api_key_env: Option<String>,
#[arg(long, env = "OPENAI_COMPATIBLE_MODEL", global = true)]
pub openai_compatible_model: Option<String>,
#[arg(
long,
env = "OPENAI_COMPATIBLE_MODELS",
value_delimiter = ',',
global = true
)]
pub openai_compatible_models: Vec<String>,
#[arg(long, env = "ACTIVITYPUB_ACTOR_BASE_URL", global = true)]
pub activitypub_actor_base_url: Option<String>,
#[arg(long, env = "ACTIVITYPUB_PUBLIC_KEY_PEM", global = true)]
pub activitypub_public_key_pem: Option<String>,
#[arg(long, env = "DISABLE_OPENAI_API", global = true)]
pub disable_openai_api: bool,
#[arg(long, env = "DISABLE_ANTHROPIC_API", global = true)]
pub disable_anthropic_api: bool,
#[arg(long, env = "DISABLE_METRICS", global = true)]
pub disable_metrics: bool,
#[arg(
long,
env = "ADDITIONAL_ACCOUNT_DIRS",
value_delimiter = ',',
global = true
)]
pub additional_account_dirs: Vec<PathBuf>,
#[arg(long, env = "EXPERIMENTAL_COMPATIBILITY", global = true)]
pub experimental_compatibility: bool,
#[arg(long, env = "TOKEN_ADMIN_KEY", global = true)]
pub admin_key: Option<String>,
#[arg(long, env = "MPP_ENABLE", global = true)]
pub mpp_enable: bool,
#[arg(long, env = "MPP_AMOUNT", default_value = "0.00", global = true)]
pub mpp_amount: String,
#[arg(long, env = "MPP_CURRENCY", default_value = "USD", global = true)]
pub mpp_currency: String,
#[arg(long, env = "MPP_RECIPIENT", global = true)]
pub mpp_recipient: Option<String>,
#[arg(long, env = "MPP_METHOD", global = true)]
pub mpp_method: Option<String>,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Serve,
Tokens {
#[command(subcommand)]
op: TokenOp,
},
Accounts {
#[command(subcommand)]
op: AccountOp,
},
Providers {
#[command(subcommand)]
op: ProviderOp,
},
Doctor,
}
#[derive(Debug, Subcommand)]
pub enum TokenOp {
Issue {
#[arg(long, default_value_t = 24)]
ttl_hours: i64,
#[arg(long, default_value = "")]
label: String,
#[arg(long)]
account: Option<String>,
},
List,
Revoke { id: String },
Expire { id: String },
Show { id: String },
}
#[derive(Debug, Subcommand)]
pub enum AccountOp {
List,
}
#[derive(Debug, Subcommand)]
pub enum ProviderOp {
List,
Add {
#[arg(long)]
name: String,
#[arg(long, default_value = "openai-compatible")]
kind: String,
#[arg(long)]
base_url: String,
#[arg(long)]
model: Option<String>,
#[arg(long, value_delimiter = ',')]
models: Vec<String>,
#[arg(long)]
api_key: Option<String>,
#[arg(long)]
api_key_env: Option<String>,
#[arg(long, default_value_t = true)]
enabled: bool,
},
Show { name: String },
Remove { name: String },
Import { path: PathBuf },
}
impl Cli {
pub fn into_config(&self) -> Result<Config, ConfigError> {
let port = self.port.to_string();
let token_secret = self.token_secret.clone();
let claude_home = self.claude_code_home.clone().unwrap_or_else(|| {
std::env::var("HOME")
.map_or_else(|_| "/root/.claude".to_string(), |h| format!("{h}/.claude"))
});
let api_format = self.api_format.as_deref().and_then(ApiFormat::from_str_opt);
let routing_mode =
RoutingMode::from_str_opt(&self.routing_mode).ok_or(ConfigError::InvalidRoutingMode)?;
let upstream_provider = UpstreamProvider::from_str_opt(&self.upstream_provider)
.unwrap_or(UpstreamProvider::Anthropic);
let storage_policy = StoragePolicy::from_str_opt(&self.storage_policy).unwrap_or_default();
let data_dir = self.data_dir.clone().unwrap_or_else(default_data_dir);
let activitypub_actor_base_url = self
.activitypub_actor_base_url
.clone()
.unwrap_or_else(|| format!("http://{}:{}", self.host, self.port));
let crater_actor = self
.crater_forgefed_actor
.clone()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
format!(
"{}/actor/code",
activitypub_actor_base_url.trim_end_matches('/')
)
});
let crater = crate::crater::CraterConfig::new(
self.crater_forgefed_inbox
.clone()
.filter(|value| !value.is_empty()),
&crater_actor,
self.crater_forgefed_target
.clone()
.filter(|value| !value.is_empty()),
Duration::from_millis(self.crater_poll_interval_ms),
Duration::from_secs(self.crater_poll_timeout_secs),
);
let activitypub_public_key_pem = self
.activitypub_public_key_pem
.clone()
.unwrap_or_else(default_activitypub_public_key_pem);
let openai_compatible = crate::providers::OpenAICompatibleConfig {
provider_name: self.openai_compatible_provider_name.clone(),
base_url: self.openai_compatible_base_url.clone(),
api_key: self
.openai_compatible_api_key
.clone()
.filter(|s| !s.is_empty()),
api_key_env: self
.openai_compatible_api_key_env
.clone()
.filter(|s| !s.is_empty()),
default_model: self
.openai_compatible_model
.clone()
.filter(|s| !s.is_empty()),
models: self.openai_compatible_models.clone(),
};
Config::build(BuildArgs {
host: &self.host,
port: &port,
token_secret: token_secret.as_deref(),
claude_code_home: &claude_home,
upstream_base_url: &self.upstream_base_url,
verbose: self.verbose,
api_format,
routing_mode,
storage_policy,
data_dir,
claude_cli_bin: self.claude_cli_bin.clone(),
upstream_provider,
gonka_private_key: self.gonka_private_key.clone().filter(|s| !s.is_empty()),
gonka_source_url: self.gonka_source_url.clone(),
gonka_model: self.gonka_model.clone(),
crater,
openai_compatible,
activitypub_actor_base_url,
activitypub_public_key_pem,
enable_openai_api: !self.disable_openai_api,
enable_anthropic_api: !self.disable_anthropic_api,
enable_metrics: !self.disable_metrics,
additional_account_dirs: self.additional_account_dirs.clone(),
experimental_compatibility: self.experimental_compatibility,
admin_key: self.admin_key.clone().filter(|s| !s.is_empty()),
mpp: crate::mpp::MppConfig {
enabled: self.mpp_enable,
amount: self.mpp_amount.clone(),
currency: self.mpp_currency.clone(),
recipient: self.mpp_recipient.clone().unwrap_or_default(),
method: self.mpp_method.clone().filter(|s| !s.is_empty()),
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
default_gonka_model, default_gonka_source_url, default_openai_compatible_base_url,
};
#[test]
fn cli_defaults_round_trip_to_config() {
let cli = Cli {
command: None,
host: "127.0.0.1".into(),
port: 9090,
verbose: false,
token_secret: Some("k".into()),
claude_code_home: Some("/tmp/c".into()),
upstream_base_url: "https://api.anthropic.com".into(),
api_format: None,
routing_mode: "direct".into(),
storage_policy: "memory".into(),
data_dir: Some(std::path::PathBuf::from("/tmp/d")),
claude_cli_bin: None,
upstream_provider: "anthropic".into(),
gonka_private_key: None,
gonka_source_url: default_gonka_source_url(),
gonka_model: default_gonka_model(),
crater_forgefed_inbox: None,
crater_forgefed_actor: None,
crater_forgefed_target: None,
crater_poll_interval_ms: 1000,
crater_poll_timeout_secs: 120,
openai_compatible_provider_name: "litellm".into(),
openai_compatible_base_url: default_openai_compatible_base_url(),
openai_compatible_api_key: None,
openai_compatible_api_key_env: None,
openai_compatible_model: None,
openai_compatible_models: vec![],
activitypub_actor_base_url: Some("https://router.example".into()),
activitypub_public_key_pem: None,
disable_openai_api: false,
disable_anthropic_api: false,
disable_metrics: false,
additional_account_dirs: vec![],
experimental_compatibility: false,
admin_key: None,
mpp_enable: false,
mpp_amount: "0.00".into(),
mpp_currency: "USD".into(),
mpp_recipient: None,
mpp_method: None,
};
let cfg = cli.into_config().unwrap();
assert_eq!(cfg.listen_addr.port(), 9090);
assert_eq!(cfg.routing_mode, RoutingMode::Direct);
assert_eq!(cfg.storage_policy, StoragePolicy::Memory);
assert!(cfg.enable_openai_api);
assert!(cfg.enable_anthropic_api);
assert!(cfg.enable_metrics);
}
#[test]
fn cli_invalid_routing_mode_rejected() {
let cli = Cli {
command: None,
host: "0.0.0.0".into(),
port: 8080,
verbose: false,
token_secret: Some("k".into()),
claude_code_home: Some("/tmp/c".into()),
upstream_base_url: "https://api.anthropic.com".into(),
api_format: None,
routing_mode: "bogus".into(),
storage_policy: "memory".into(),
data_dir: None,
claude_cli_bin: None,
upstream_provider: "anthropic".into(),
gonka_private_key: None,
gonka_source_url: default_gonka_source_url(),
gonka_model: default_gonka_model(),
crater_forgefed_inbox: None,
crater_forgefed_actor: None,
crater_forgefed_target: None,
crater_poll_interval_ms: 1000,
crater_poll_timeout_secs: 120,
openai_compatible_provider_name: "litellm".into(),
openai_compatible_base_url: default_openai_compatible_base_url(),
openai_compatible_api_key: None,
openai_compatible_api_key_env: None,
openai_compatible_model: None,
openai_compatible_models: vec![],
activitypub_actor_base_url: None,
activitypub_public_key_pem: None,
disable_openai_api: false,
disable_anthropic_api: false,
disable_metrics: false,
additional_account_dirs: vec![],
experimental_compatibility: false,
admin_key: None,
mpp_enable: false,
mpp_amount: "0.00".into(),
mpp_currency: "USD".into(),
mpp_recipient: None,
mpp_method: None,
};
let r = cli.into_config();
assert!(matches!(r, Err(ConfigError::InvalidRoutingMode)));
}
}