unigateway 1.2.1

Lightweight, local-first LLM gateway for developers. A stable, single-binary unified entry point for all your AI tools and models.
use anyhow::{Context, Result, bail};
use console::style;
use indicatif::{ProgressBar, ProgressStyle};
use reqwest::Client;
use serde_json::Value;
use std::path::Path;
use std::time::Duration;

use crate::{
    config::{ModeProvider, ModeView},
    routing::resolve_upstream,
    types::AppConfig,
};

use super::{
    modes::{
        effective_default_mode_id, load_mode_views, mode_providers_for, pick_mode_key,
        pick_mode_protocol, provider_default_model, select_mode, supported_protocols,
        user_anthropic_base_url, user_bind_address, user_openai_base_url,
    },
    render::routes::route_strategy_summary,
};

pub(crate) fn summarize_response_text(body: &str) -> String {
    let streamed = body
        .lines()
        .filter_map(|line| line.strip_prefix("data: "))
        .filter(|line| !line.trim().is_empty() && *line != "[DONE]")
        .filter_map(|line| serde_json::from_str::<Value>(line).ok())
        .filter_map(|value| {
            value
                .pointer("/choices/0/delta/content")
                .and_then(Value::as_str)
                .or_else(|| {
                    value
                        .pointer("/choices/0/message/content")
                        .and_then(Value::as_str)
                })
                .or_else(|| value.pointer("/content/0/text").and_then(Value::as_str))
                .map(ToOwned::to_owned)
        })
        .collect::<String>();
    if !streamed.trim().is_empty() {
        return if streamed.len() > 160 {
            format!("{}...", &streamed[..160])
        } else {
            streamed
        };
    }

    let parsed = serde_json::from_str::<Value>(body).ok();
    let text = parsed
        .as_ref()
        .and_then(|value| {
            value
                .pointer("/choices/0/message/content")
                .and_then(Value::as_str)
                .or_else(|| value.pointer("/content/0/text").and_then(Value::as_str))
                .or_else(|| value.pointer("/error/message").and_then(Value::as_str))
        })
        .unwrap_or(body)
        .trim();

    if text.len() > 160 {
        format!("{}...", &text[..160])
    } else {
        text.to_string()
    }
}

async fn gateway_health_status(bind_override: Option<&str>) -> (bool, String) {
    let bind = bind_override
        .map(ToOwned::to_owned)
        .unwrap_or_else(|| AppConfig::from_env().bind);
    let url = format!("http://{}/health", user_bind_address(&bind));
    let client = Client::new();

    match client.get(&url).send().await {
        Ok(response) => {
            let status = response.status();
            if !status.is_success() {
                return (
                    false,
                    format!("gateway responded with status {} at {}", status, url),
                );
            }

            match response.text().await {
                Ok(body) => {
                    let message = serde_json::from_str::<Value>(&body)
                        .ok()
                        .and_then(|value| {
                            value
                                .get("status")
                                .and_then(Value::as_str)
                                .map(ToOwned::to_owned)
                        })
                        .unwrap_or_else(|| "ok".to_string());
                    (true, format!("reachable ({}) at {}", message, url))
                }
                Err(err) => (
                    false,
                    format!(
                        "gateway reachable at {}, but health body could not be read: {}",
                        url, err
                    ),
                ),
            }
        }
        Err(_) => (false, format!("not reachable at {}", url)),
    }
}

fn provider_readiness(provider: &ModeProvider) -> String {
    let upstream =
        if resolve_upstream(provider.base_url.clone(), provider.endpoint_id.as_deref()).is_some() {
            style("✓ resolved").green()
        } else {
            style("✗ missing upstream").red()
        };
    let api_key = if provider.has_api_key {
        style("✓ key configured").green()
    } else {
        style("✗ missing key").red()
    };
    format!("{} | {}", upstream, api_key)
}

pub async fn doctor(
    config_path: &str,
    mode_id: Option<&str>,
    bind_override: Option<&str>,
) -> Result<()> {
    let config_exists = Path::new(config_path).exists();
    let modes = load_mode_views(config_path).await?;
    let default_mode = effective_default_mode_id(&modes).map(ToOwned::to_owned);
    let (is_healthy, health) = gateway_health_status(bind_override).await;
    let bind_display = bind_override
        .map(ToOwned::to_owned)
        .unwrap_or_else(|| AppConfig::from_env().bind);

    println!("{}", style("🩺 UniGateway Doctor").bold());
    println!("-------------------");
    println!("Config Path:   {}", config_path);
    println!(
        "Config Status: {}",
        if config_exists {
            style("✓ present").green()
        } else {
            style("✗ missing (using in-memory defaults if started)").red()
        }
    );
    println!("Gateway Bind:   {}", bind_display);
    println!(
        "Gateway Health: {}",
        if is_healthy {
            style(format!("{}", health)).green()
        } else {
            style(format!("{}", health)).red()
        }
    );

    if !is_healthy {
        println!(
            "\n💡 Tip: The gateway is not running. Start it with `ug serve` to enable LLM routing."
        );
    }

    if modes.is_empty() {
        println!("\nModes: {}", style("none").yellow());
        println!("Next step: run `ug guide` to set up your first provider.");
        return Ok(());
    }

    let selected: Vec<&ModeView> = if let Some(mode_id) = mode_id {
        vec![select_mode(&modes, Some(mode_id))?]
    } else {
        modes.iter().collect()
    };

    println!("\nModes Checked: {}", selected.len());
    for mode in selected {
        let protocols = supported_protocols(mode);
        let active_keys = mode.keys.iter().filter(|key| key.is_active).count();
        let is_default = default_mode.as_deref() == Some(mode.id.as_str());

        println!("\n- {} ({})", style(&mode.id).bold(), mode.name);
        if is_default {
            println!("  {}", style("★ Default Mode").yellow());
        }
        println!("  Routing:   {}", mode.routing_strategy);
        println!(
            "  Auth:      {} / {} active keys",
            active_keys,
            mode.keys.len()
        );
        let protocol_list = protocols.join(", ");
        println!(
            "  Protocols: {}",
            if protocols.is_empty() {
                "none"
            } else {
                &protocol_list
            }
        );

        if active_keys == 0 {
            println!(
                "  {}",
                style("⚠️ Warning: No active gateway key for this mode. Requests will fail.")
                    .yellow()
            );
        }

        for protocol in protocols {
            let providers = mode_providers_for(mode, protocol);
            println!(
                "  {} Route: {}",
                protocol.to_uppercase(),
                route_strategy_summary(mode, &providers)
            );

            for provider in providers {
                let (resolved_base_url, family_id) =
                    resolve_upstream(provider.base_url.clone(), provider.endpoint_id.as_deref())
                        .unwrap_or_else(|| {
                            (
                                provider
                                    .base_url
                                    .clone()
                                    .unwrap_or_else(|| "(unresolved)".to_string()),
                                None,
                            )
                        });
                println!(
                    "{}: {} [family={}]",
                    provider.name,
                    resolved_base_url,
                    family_id.as_deref().unwrap_or("-"),
                );
                println!("      Status: {}", provider_readiness(provider));
            }
        }

        let disabled = mode
            .providers
            .iter()
            .filter(|provider| !provider.is_enabled)
            .count();
        if disabled > 0 {
            println!("  ℹ️ Note: {} bound provider(s) are disabled", disabled);
        }

        println!("\n  Next Steps:");
        println!(
            "    ug route explain {}  # See detailed routing logic",
            mode.id
        );
        println!(
            "    ug test --mode {}     # Send a smoke test request",
            mode.id
        );
        println!(
            "    ug integrations --mode {} # Get tool config snippets",
            mode.id
        );
    }

    Ok(())
}

pub async fn test_mode(
    config_path: &str,
    mode_id: Option<&str>,
    protocol: Option<&str>,
    bind_override: Option<&str>,
) -> Result<()> {
    let modes = load_mode_views(config_path).await?;
    let mode = select_mode(&modes, mode_id)?;
    let key = pick_mode_key(mode)?;
    let protocol = pick_mode_protocol(mode, protocol)?;
    let client = Client::new();

    let (url, request) = match protocol {
        "openai" => {
            let provider = mode_providers_for(mode, "openai")
                .into_iter()
                .next()
                .with_context(|| format!("mode '{}' has no openai provider", mode.id))?;
            let model = provider_default_model(provider, "gpt-4o-mini");
            (
                format!("{}/chat/completions", user_openai_base_url(bind_override)),
                client
                    .post(format!(
                        "{}/chat/completions",
                        user_openai_base_url(bind_override)
                    ))
                    .bearer_auth(&key)
                    .json(&serde_json::json!({
                        "model": model,
                        "messages": [{"role": "user", "content": "reply with ok"}],
                        "max_tokens": 16,
                        "stream": true
                    })),
            )
        }
        "anthropic" => {
            let provider = mode_providers_for(mode, "anthropic")
                .into_iter()
                .next()
                .with_context(|| format!("mode '{}' has no anthropic provider", mode.id))?;
            let model = provider_default_model(provider, "claude-3-5-sonnet-latest");
            (
                format!("{}/v1/messages", user_anthropic_base_url(bind_override)),
                client
                    .post(format!(
                        "{}/v1/messages",
                        user_anthropic_base_url(bind_override)
                    ))
                    .header("x-api-key", &key)
                    .header("anthropic-version", "2023-06-01")
                    .json(&serde_json::json!({
                        "model": model,
                        "max_tokens": 32,
                        "messages": [{"role": "user", "content": "reply with ok"}],
                        "stream": true
                    })),
            )
        }
        _ => bail!("unsupported protocol '{}'", protocol),
    };

    let pb = ProgressBar::new_spinner();
    pb.set_style(
        ProgressStyle::default_spinner()
            .template("{spinner:.green} {msg}")
            .unwrap(),
    );
    pb.set_message(format!("Testing mode '{}' via {}...", mode.id, protocol));
    pb.enable_steady_tick(Duration::from_millis(100));

    let response = request.send().await;

    pb.finish_and_clear();

    let response = response.with_context(|| {
        format!(
            "failed to connect to {}. Start the gateway with `ug serve` and try again",
            url
        )
    })?;

    let status = response.status();
    let body = response
        .text()
        .await
        .context("read gateway response body")?;

    if !status.is_success() {
        bail!(
            "smoke test failed for mode '{}' via {} (status {}): {}",
            mode.id,
            protocol,
            status,
            summarize_response_text(&body)
        );
    }

    println!(
        "{} Mode '{}' passed {} smoke test: {}",
        style("").green(),
        mode.id,
        protocol,
        style(summarize_response_text(&body)).dim()
    );
    Ok(())
}