use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use axum::routing::{get, post};
use axum::Router;
use link_assistant_router::accounts::{AccountRouter, SelectionStrategy};
use link_assistant_router::activitypub;
use link_assistant_router::cli::{AccountOp, Cli, Command, TokenOp};
use link_assistant_router::config::{Config, RoutingMode, StoragePolicy};
use link_assistant_router::metrics::Metrics;
use link_assistant_router::oauth::OAuthProvider;
use link_assistant_router::proxy::{self, AppState};
use link_assistant_router::storage::{build_token_store, TokenStore};
use link_assistant_router::token::TokenManager;
use log_lazy::{levels, LogLazy};
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
type SharedState = (Arc<dyn TokenStore>, Option<AccountRouter>);
type AnyError = Box<dyn std::error::Error>;
#[tokio::main]
async fn main() -> ExitCode {
let cli = <Cli as lino_arguments::Parser>::parse();
let verbose = cli.verbose;
init_tracing(verbose);
let logger = build_logger(verbose);
tracing::info!("Link.Assistant.Router v{}", link_assistant_router::VERSION);
if verbose {
tracing::info!("Verbose logging enabled");
}
let config = match cli.into_config() {
Ok(c) => c,
Err(e) => {
tracing::error!("Configuration error: {e}");
return ExitCode::from(2);
}
};
match cli.command.as_ref() {
None | Some(Command::Serve) => match run_server(config, logger).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::error!("server error: {e}");
ExitCode::from(1)
}
},
Some(Command::Tokens { op }) => run_tokens(&config, op),
Some(Command::Accounts { op }) => run_accounts(&config, op),
Some(Command::Doctor) => run_doctor(&config),
}
}
fn init_tracing(verbose: bool) {
let default_filter = if verbose { "debug" } else { "info" };
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::from_default_env().add_directive(default_filter.parse().unwrap()),
)
.init();
}
fn build_logger(verbose: bool) -> LogLazy {
let log_level = if verbose {
levels::ALL
} else {
levels::PRODUCTION
};
LogLazy::with_sink(log_level, |level, message| match level {
log_lazy::Level::FATAL | log_lazy::Level::ERROR => tracing::error!("{message}"),
log_lazy::Level::WARN => tracing::warn!("{message}"),
log_lazy::Level::INFO => tracing::info!("{message}"),
log_lazy::Level::DEBUG => tracing::debug!("{message}"),
_ => tracing::trace!("{message}"),
})
}
fn build_shared_state(config: &Config) -> Result<SharedState, AnyError> {
if !config.data_dir.exists() {
std::fs::create_dir_all(&config.data_dir)?;
}
let store = build_token_store(config.storage_policy, &config.data_dir)?;
let account_router = if config.additional_account_dirs.is_empty() {
None
} else {
Some(AccountRouter::new(
std::path::PathBuf::from(&config.claude_code_home),
&config.additional_account_dirs,
SelectionStrategy::default(),
Duration::from_secs(60),
))
};
Ok((store, account_router))
}
async fn run_server(config: Config, logger: LogLazy) -> Result<(), Box<dyn std::error::Error>> {
tracing::info!("Upstream: {}", config.upstream_base_url);
tracing::info!("Upstream provider: {:?}", config.upstream_provider);
tracing::info!("Claude Code home: {}", config.claude_code_home);
tracing::info!("Routing mode: {:?}", config.routing_mode);
tracing::info!("Storage policy: {:?}", config.storage_policy);
if config.routing_mode == RoutingMode::Cli || config.routing_mode == RoutingMode::Hybrid {
tracing::warn!(
"RoutingMode::{:?} is configured but the CLI backend is not yet wired; falling back to direct.",
config.routing_mode
);
}
let (store, account_router) = build_shared_state(&config)?;
if let Some(router) = account_router.as_ref() {
tracing::info!("Multi-account routing enabled ({} accounts)", router.len());
}
let token_manager = TokenManager::with_store(&config.token_secret, store);
let oauth_provider = OAuthProvider::new(&config.claude_code_home);
let metrics = Arc::new(Metrics::default());
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let state = AppState {
client,
token_manager,
oauth_provider,
account_router,
upstream_base_url: config.upstream_base_url.clone(),
upstream_provider: config.upstream_provider,
gonka: link_assistant_router::gonka::GonkaConfig::new(
config.gonka_private_key.clone(),
&config.gonka_source_url,
config.gonka_model.clone(),
),
logger,
admin_key: config.admin_key.clone(),
metrics: Arc::clone(&metrics),
activitypub_actor_base_url: config.activitypub_actor_base_url.clone(),
activitypub_public_key_pem: config.activitypub_public_key_pem.clone(),
};
let mut app = Router::new()
.route("/health", get(proxy::health))
.route("/actor/code", get(activitypub::actor))
.route("/inbox/code", post(activitypub::inbox))
.route("/outbox/code", get(activitypub::outbox))
.route("/actors/code/followers", get(activitypub::followers))
.route(
"/activities/follow-problemsets-code-001",
get(activitypub::follow_problemsets),
)
.route("/api/tokens", post(proxy::issue_token))
.route("/api/tokens/list", get(proxy::list_tokens))
.route("/api/tokens/revoke", post(proxy::revoke_token));
if config.enable_anthropic_api {
app = app
.route("/v1/messages", post(proxy::proxy_handler))
.route("/v1/messages/count_tokens", post(proxy::proxy_handler))
.route("/invoke", post(proxy::proxy_handler))
.route("/invoke-with-response-stream", post(proxy::proxy_handler));
}
if config.enable_openai_api {
app = app
.route("/v1/chat/completions", post(proxy::openai_chat_completions))
.route("/v1/responses", post(proxy::openai_responses))
.route("/v1/models", get(proxy::openai_models));
}
if config.enable_metrics {
app = app
.route("/metrics", get(proxy::metrics_endpoint))
.route("/v1/usage", get(proxy::usage_endpoint))
.route("/v1/accounts", get(proxy::accounts_endpoint));
}
let app = app
.fallback(proxy::proxy_handler)
.with_state(state)
.layer(TraceLayer::new_for_http());
tracing::info!("Listening on {}", config.listen_addr);
let listener = tokio::net::TcpListener::bind(config.listen_addr).await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
fn run_tokens(config: &Config, op: &TokenOp) -> ExitCode {
let (store, _account_router) = match build_shared_state(config) {
Ok(v) => v,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::from(1);
}
};
let mgr = TokenManager::with_store(&config.token_secret, store);
match op {
TokenOp::Issue {
ttl_hours,
label,
account,
} => match mgr.issue_token_for(*ttl_hours, label, account.as_deref()) {
Ok(t) => {
println!("{t}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(1)
}
},
TokenOp::List => match mgr.list_tokens() {
Ok(records) => {
println!(
"{:<36} {:<10} {:<10} {:<10} label",
"id", "issued_at", "expires_at", "revoked"
);
for r in records {
println!(
"{:<36} {:<10} {:<10} {:<10} {}",
r.id, r.issued_at, r.expires_at, r.revoked, r.label
);
}
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(1)
}
},
TokenOp::Revoke { id } | TokenOp::Expire { id } => match mgr.revoke_token(id) {
Ok(()) => {
println!("revoked {id}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(1)
}
},
TokenOp::Show { id } => match mgr.list_tokens() {
Ok(records) => records.into_iter().find(|r| r.id == *id).map_or_else(
|| {
eprintln!("not found: {id}");
ExitCode::from(2)
},
|r| {
println!("{}", serde_json::to_string_pretty(&r).unwrap_or_default());
ExitCode::SUCCESS
},
),
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(1)
}
},
}
}
fn run_accounts(config: &Config, op: &AccountOp) -> ExitCode {
let router = match build_shared_state(config) {
Ok((_, Some(r))) => r,
Ok((_, None)) => {
AccountRouter::new(
std::path::PathBuf::from(&config.claude_code_home),
&[],
SelectionStrategy::default(),
Duration::from_secs(60),
)
}
Err(e) => {
eprintln!("error: {e}");
return ExitCode::from(1);
}
};
match op {
AccountOp::List => {
let snap = router.health_snapshot();
println!("{:<16} {:<8} {:<6} home", "name", "healthy", "used");
for h in snap {
println!(
"{:<16} {:<8} {:<6} {}",
h.name,
h.healthy,
h.used,
h.home.display()
);
}
ExitCode::SUCCESS
}
}
}
fn run_doctor(config: &Config) -> ExitCode {
println!("Link.Assistant.Router v{}", link_assistant_router::VERSION);
println!("listen_addr : {}", config.listen_addr);
println!("upstream_base_url : {}", config.upstream_base_url);
println!("claude_code_home : {}", config.claude_code_home);
println!("routing_mode : {:?}", config.routing_mode);
println!("storage_policy : {:?}", config.storage_policy);
println!("data_dir : {}", config.data_dir.display());
println!("enable_openai_api : {}", config.enable_openai_api);
println!("enable_anthropic_api : {}", config.enable_anthropic_api);
println!("enable_metrics : {}", config.enable_metrics);
println!(
"additional_account_dirs: {} configured",
config.additional_account_dirs.len()
);
println!(
"admin_key : {}",
if config.admin_key.is_some() {
"set"
} else {
"<unset>"
}
);
let probe_path = std::path::Path::new(&config.claude_code_home).join("credentials.json");
println!(
"primary credentials : {} ({})",
probe_path.display(),
if probe_path.exists() {
"found"
} else {
"MISSING"
}
);
for (i, dir) in config.additional_account_dirs.iter().enumerate() {
let p = dir.join("credentials.json");
println!(
"extra account {} : {} ({})",
i + 1,
p.display(),
if p.exists() { "found" } else { "MISSING" }
);
}
if config.data_dir.exists() {
println!("data_dir : present");
} else {
println!("data_dir : will be created on first write");
}
if matches!(
config.storage_policy,
StoragePolicy::Text | StoragePolicy::Both
) {
let p = config.data_dir.join("tokens.lino");
println!(
"lino store : {} ({})",
p.display(),
if p.exists() { "present" } else { "<empty>" }
);
}
if matches!(
config.storage_policy,
StoragePolicy::Binary | StoragePolicy::Both
) {
let p = config.data_dir.join("tokens.bin");
println!(
"binary store : {} ({})",
p.display(),
if p.exists() { "present" } else { "<empty>" }
);
}
ExitCode::SUCCESS
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("Failed to install CTRL+C signal handler");
tracing::info!("Shutdown signal received");
}