Files
cortiq-gateway/src/main.rs
T
infosave2007 15de36be3b feat: ops & UX batch — jobs survive restarts, logs/system in the console, media queue, gallery, chat, exact stream tokens
Fifteen changes aimed at the first day of real use:

- Import and media jobs persist to <data>/jobs-*.json and reload on start
  (running ones settle as errors); the Register button no longer vanishes
  because the gateway restarted.
- Docker ships the cortiq binary (built in the builder stage) — local .cmf
  chat models and image/video generation now work in containers, where
  auto-install never could (no cargo in the runtime image). libvulkan1 added
  for optional GPU offload.
- Open admin console on 0.0.0.0 without a token now warns in the log and
  shows a dismissible banner — the VPS footgun is at least labeled.
- "Restart gateway" button in Settings: stops managed model servers, spawns
  a fresh copy (bind retried while the port frees), Docker relies on the
  container restart policy instead.
- Dashboard "System" panel: CPU sparkline + load, RAM (with the gateway's
  own RSS), free disk at the models dir — sampled by sysinfo every 2 s.
- Featured model cards warn "not enough disk" / "may not fit in RAM" before
  the download, from the same live snapshot.
- Log viewer in the sidebar: a 500-line in-memory ring fed by a second
  ANSI-free tracing layer; filter, level, pause, copy.
- Update check: crates.io polled twice a day, the console shows an
  "update available" notice.
- Media generations queue up (with position shown) instead of being
  rejected; progress gained an honest ETA from measured throughput.
- Generation gallery under the image/video tabs: lazy PPM thumbnails,
  click to reopen with seed/steps, delete.
- Playground chat is a real dialogue now: history is resent, bubbles,
  Clear, Cmd/Ctrl+Enter.
- "Connect your app" snippets (curl / Python / JS) on the keys page, with
  the real key inlined right after creation.
- One-click benchmark on the Models page via `cortiq bench --json`.
- Config export now downloads the actual gateway.toml (comments intact);
  import accepts TOML (validated, hot-reloaded) and the legacy JSON.
- Streaming token counts are exact when the model server provides usage
  (cortiq-server patch, shipped separately); the gateway fallback now
  counts content chunks (1/token on cortiq) instead of chars/4.

Wizard's Finish lands on the Playground. 37 new strings in all 7 languages.
2026-08-07 17:07:48 +03:00

346 lines
12 KiB
Rust

//! Cortiq Gateway — entry point.
//!
//! Loads the config, builds the shared state, assembles routes from protocols and
//! the admin panel, and starts the HTTP server. See docs/ARCHITECTURE.md.
// Part of the canonical model / error API surface (auth, rate-limit, idempotency,
// traceparent, vision/streaming caps) is intentionally reserved for upcoming phases;
// allow the unused items until those features land.
#![allow(dead_code)]
mod admin;
mod bench;
mod cache;
mod cmf_runtime;
mod config;
mod error;
mod import;
mod logbuf;
mod media;
mod model;
mod pipeline;
mod promotion;
mod protocols;
mod providers;
mod registry;
mod router_client;
mod routing;
mod secrets;
mod state;
mod stats;
mod sysmon;
use axum::{routing::get, Router};
use clap::Parser;
use config::Config;
use state::SharedState;
#[derive(Parser)]
#[command(
name = "cortiq-gateway",
version,
about = "Universal LLM gateway with intelligent routing"
)]
struct Args {
/// Path to the TOML config file. If omitted, uses `config/gateway.toml` when running
/// inside the repo, otherwise `~/.cortiq-gateway/gateway.toml` (or `$CORTIQ_GATEWAY_HOME`).
/// Missing file is created automatically — no manual copying needed.
#[arg(long)]
config: Option<String>,
/// Admin token for the web panel (overrides `[admin].token_env`).
#[arg(long)]
admin_token: Option<String>,
/// Do not automatically open the browser on first run.
#[arg(long)]
no_browser: bool,
/// Do not auto-select a free port if the configured one is busy.
#[arg(long)]
no_port_search: bool,
}
/// Resolve the admin token: env from `[admin].token_env` → `--admin-token` →
///
/// By default no token is required (open mode). User sets it explicitly via
/// `--admin-token`, `[admin].token` or `[admin].token_env` if they want auth.
fn resolve_admin_token(cfg: &Config, args: &Args) -> String {
if let Some(env) = &cfg.admin.token_env {
if let Ok(v) = std::env::var(env) {
if !v.is_empty() {
return v;
}
}
}
if let Some(t) = &args.admin_token {
if !t.is_empty() {
return t.clone();
}
}
if let Some(t) = &cfg.admin.token {
if !t.is_empty() {
return t.clone();
}
}
String::new()
}
fn is_docker() -> bool {
std::path::Path::new("/.dockerenv").exists()
|| std::fs::read_to_string("/proc/1/cgroup")
.map(|s| s.contains("docker") || s.contains("kubepods"))
.unwrap_or(false)
}
fn should_open_browser(args: &Args) -> bool {
if args.no_browser {
return false;
}
if std::env::var("CORTIQ_NO_BROWSER")
.map(|v| !v.is_empty() && v != "0")
.unwrap_or(false)
{
return false;
}
if std::env::var("CI").is_ok() || std::env::var("NO_BROWSER").is_ok() {
return false;
}
if is_docker() {
return false;
}
true
}
fn parse_listen(listen: &str) -> (String, u16) {
if let Some((host, port_s)) = listen.rsplit_once(':') {
if let Ok(p) = port_s.parse::<u16>() {
return (host.to_string(), p);
}
}
(listen.to_string(), 9000)
}
fn is_port_free(host: &str, port: u16) -> bool {
let addr = format!("{host}:{port}");
// bind test + connect test (docker port forwarding can make bind succeed while port is still forwarded)
if std::net::TcpListener::bind(&addr).is_err() {
return false;
}
use std::net::ToSocketAddrs;
let probe_host = if host == "0.0.0.0" { "127.0.0.1" } else { host };
if let Ok(mut addrs) = (probe_host, port).to_socket_addrs() {
if let Some(a) = addrs.next() {
if std::net::TcpStream::connect_timeout(&a, std::time::Duration::from_millis(200))
.is_ok()
{
return false;
}
}
}
true
}
fn resolve_listen(cfg: &mut Config, no_search: bool) -> String {
if no_search {
return cfg.listen.clone();
}
let (host, port) = parse_listen(&cfg.listen);
let clean_host = if host.is_empty() {
"0.0.0.0".to_string()
} else {
host
};
if is_port_free(&clean_host, port) {
return cfg.listen.clone();
}
for p in (port + 1)..=port.saturating_add(50) {
if is_port_free(&clean_host, p) {
let new_listen = format!("{clean_host}:{p}");
eprintln!("port {port} busy — switching to free port {p} ({new_listen})");
cfg.listen = new_listen.clone();
return new_listen;
}
}
if !is_port_free(&clean_host, port) {
eprintln!(
"warning: port {port} busy and no free port found in next 50, will try to bind anyway"
);
}
cfg.listen.clone()
}
fn setup_marker_path(config_path: &str) -> std::path::PathBuf {
// marker lives next to config file: gateway.toml -> .setup_done
std::path::Path::new(config_path)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join(".setup_done")
}
fn needs_first_run_wizard(cfg: &Config, config_path: &str) -> bool {
let marker = setup_marker_path(config_path);
if marker.exists() {
return false;
}
// also consider "needs setup" if model pool is empty and no managed cmf
if !cfg.models.is_empty() || !cfg.cmf.effective_servers().is_empty() {
// still show wizard once if marker missing, but not if user already has models
// we treat missing marker + existing models as "already configured" to avoid nagging
// persist marker lazily
let _ = std::fs::write(&marker, "done");
return false;
}
true
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
let config_path = args
.config
.clone()
.unwrap_or_else(config::default_config_path);
let mut cfg = Config::load_or_create(&config_path)?;
// auto-pick a free port if the configured one is busy (unless disabled)
let original_listen = cfg.listen.clone();
let listen = resolve_listen(&mut cfg, args.no_port_search);
// persist the resolved port if it changed
if listen != original_listen {
let _ = cfg.save(&config_path);
}
{
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| cfg.log.level.clone().into());
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
// second copy of every line, without ANSI, into the in-memory ring
// that backs the admin console's Логи view
.with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(logbuf::RingMakeWriter),
)
.init();
}
let admin_enabled = cfg.admin.enabled;
let admin_token = resolve_admin_token(&cfg, &args);
let first_run = needs_first_run_wizard(&cfg, &config_path);
let state = SharedState::build(cfg, config_path.clone())?;
// Open admin console reachable from other machines: fine on a laptop,
// a footgun on a VPS. Flag it for the UI banner and say it in the log.
let host_public = listen.starts_with("0.0.0.0") || listen.starts_with("[::]");
if admin_enabled && admin_token.is_empty() && host_public && !is_docker() {
state
.admin_open_public
.store(true, std::sync::atomic::Ordering::Relaxed);
tracing::warn!(
"admin console is OPEN (no token) and listening on {listen} — anyone on your network can manage this gateway; set a token via --admin-token or [admin].token if this machine is reachable from outside"
);
}
// Close the loop with the CMF format (github.com/infosave2007/cmf): install
// / update cortiq-cli and run a local `cortiq serve` if configured. Runs in
// the background so startup is never blocked by a cargo install or a model
// load; the served model is registered in the pool and usable offline.
{
let cmf_rt = state.cmf.clone();
let cmf_cfg = state.live().cfg.cmf.clone();
tokio::spawn(async move { cmf_runtime::manage(cmf_rt, cmf_cfg).await });
}
let mut app = Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/health", get(|| async { "ok" }))
.route("/readyz", get(|| async { "ready" }))
.route("/metrics", get(admin::metrics))
.merge(protocols::build_router());
if admin_enabled {
app = app
.merge(admin::api_routes(admin_token.clone()))
.fallback(admin::assets::fallback);
}
let cors = tower_http::cors::CorsLayer::new()
.allow_origin(tower_http::cors::Any)
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any);
let app = app.with_state(state).layer(cors);
let listener = {
let mut attempt = 0u32;
loop {
match tokio::net::TcpListener::bind(&listen).await {
Ok(l) => break l,
Err(e) => {
// during self-restart the old process is still letting go of
// the port — retry for a few seconds before giving up
let retrying = std::env::var("CORTIQ_BIND_RETRY").is_ok();
if retrying && attempt < 50 {
attempt += 1;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
continue;
}
return Err(e.into());
}
}
}
};
tracing::info!("cortiq-gateway listening on {listen}");
if admin_enabled {
let host_for_url = if listen.starts_with("0.0.0.0:") {
listen.replacen("0.0.0.0", "127.0.0.1", 1)
} else {
listen.clone()
};
// On first run open the onboarding wizard, otherwise the normal console.
let admin_path = if first_run {
"/admin#/onboarding"
} else {
"/admin"
};
let url = if admin_token.is_empty() {
format!("http://{host_for_url}{admin_path}")
} else {
format!("http://{host_for_url}{admin_path}?token={admin_token}")
};
if admin_token.is_empty() {
tracing::info!("admin console (open, no token): http://{host_for_url}/admin");
} else {
tracing::info!("admin console: http://{host_for_url}/admin");
}
if first_run {
tracing::info!("first run — opening onboarding wizard at {url}");
}
if admin_enabled && should_open_browser(&args) {
let browser_url = url.clone();
tokio::spawn(async move {
// give server a moment to be ready
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
#[cfg(feature = "open-browser")]
{
if let Err(e) = open::that(&browser_url) {
tracing::warn!("failed to open browser: {e}");
} else {
tracing::info!("opened browser at {browser_url}");
}
}
#[cfg(not(feature = "open-browser"))]
{
tracing::info!("open browser: {browser_url} (feature disabled)");
}
});
} else if first_run {
tracing::info!("open this URL to finish setup: {url}");
}
}
axum::serve(listener, app).await?;
Ok(())
}