release: v0.2.37 — zero-argument startup, first-run wizard, real download progress

Startup no longer needs arguments: the config is created on first launch, a busy
port is swapped for a free one, the console is open by default, and the browser
lands on a setup wizard that downloads a ready-made .cmf model and wires it into
routing (managed server + routing.default) when you press Finish.

Download progress was reported from curl's own meter, which prints a full
0→100% bar for the -L redirect hop before the transfer starts — so every job
showed 100% and then hung. Progress now comes from bytes on disk against the
size from the HuggingFace tree (or a HEAD request).

The UI and the gateway also disagreed about the output file name, so a finished
download never marked its card as downloaded; the gateway now returns the exact
stem and the UI sends it back verbatim for download, progress and delete.

Featured models are read live from the infosave HF account in both the wizard
and Import, and all 432 UI strings now exist in every one of the 7 languages.
This commit is contained in:
infosave2007
2026-08-06 17:56:42 +03:00
parent ad438a5dcb
commit 9ec8861464
25 changed files with 3154 additions and 234 deletions
+171 -18
View File
@@ -38,42 +38,160 @@ use state::SharedState;
about = "Universal LLM gateway with intelligent routing"
)]
struct Args {
/// Path to the TOML config file.
#[arg(long, default_value = "config/gateway.toml")]
config: String,
/// 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` →
/// generate a random one. Returns `(token, generated)`.
fn resolve_admin_token(cfg: &Config, args: &Args) -> (String, bool) {
///
/// 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, false);
return v;
}
}
}
if let Some(t) = &args.admin_token {
if !t.is_empty() {
return (t.clone(), false);
return t.clone();
}
}
if let Some(t) = &cfg.admin.token {
if !t.is_empty() {
return (t.clone(), false);
return t.clone();
}
}
(admin::random_token(24), true)
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 cfg = Config::load(&args.config)?;
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);
}
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
@@ -81,11 +199,11 @@ async fn main() -> anyhow::Result<()> {
)
.init();
let listen = cfg.listen.clone();
let admin_enabled = cfg.admin.enabled;
let (admin_token, generated) = resolve_admin_token(&cfg, &args);
let admin_token = resolve_admin_token(&cfg, &args);
let first_run = needs_first_run_wizard(&cfg, &config_path);
let state = SharedState::build(cfg, args.config.clone())?;
let state = SharedState::build(cfg, config_path.clone())?;
// 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
@@ -120,11 +238,46 @@ async fn main() -> anyhow::Result<()> {
let listener = tokio::net::TcpListener::bind(&listen).await?;
tracing::info!("cortiq-gateway listening on {listen}");
if admin_enabled {
tracing::info!("admin console: http://{listen}/admin");
if generated {
tracing::warn!(
"admin token (auto-generated, set [admin].token_env to persist): {admin_token}"
);
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?;