/**
 * Model Gateway
 *
 * Aggregates models from multiple local providers (Ollama, llama-server) and
 * exposes a unified llama.cpp-router-compatible HTTP API that avis-web can talk to.
 *
 * Endpoints served:
 *   GET  /props                  — synthetic llama.cpp props (role: "router")
 *   GET  /v1/models              — merged model list from all running providers
 *   POST /v1/chat/completions    — proxied to the correct backend by model id
 *   GET  /models                 — alias for /v1/models (router mode)
 */
import * as fs from "node:fs";
import * as os from "node:os";
import * as nodePath from "node:path";
const PROVIDERS = [
    { id: "ollama", baseUrl: "http://localhost:11434", type: "ollama" },
    { id: "llama-server", baseUrl: "http://localhost:8787", type: "llama-server" },
];
// model id → provider baseUrl (populated on each /v1/models call)
const modelRouteTable = new Map();
// ---------------------------------------------------------------------------
// Provider probing
// ---------------------------------------------------------------------------
async function probe(url, timeoutMs = 1500) {
    try {
        const ctrl = new AbortController();
        const t = setTimeout(() => ctrl.abort(), timeoutMs);
        const res = await fetch(url, { signal: ctrl.signal });
        clearTimeout(t);
        return res.ok;
    }
    catch {
        return false;
    }
}
async function fetchOllamaModels(baseUrl) {
    try {
        const res = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(3000) });
        if (!res.ok)
            return [];
        const json = (await res.json());
        return (json.models ?? []).map((m) => ({
            id: m.name,
            name: m.name,
            provider: "ollama",
            backendUrl: baseUrl,
            sizeBytes: m.size,
            status: "loaded",
            details: m.details,
        }));
    }
    catch {
        return [];
    }
}
async function fetchLlamaServerModels(baseUrl) {
    try {
        const res = await fetch(`${baseUrl}/v1/models`, { signal: AbortSignal.timeout(3000) });
        if (!res.ok)
            return [];
        const json = (await res.json());
        return (json.data ?? []).map((m) => ({
            id: m.id,
            name: m.id,
            provider: "local",
            backendUrl: baseUrl,
            status: "loaded",
        }));
    }
    catch {
        return [];
    }
}
// ---------------------------------------------------------------------------
// Aggregate models from all providers
// ---------------------------------------------------------------------------
export async function aggregateModels() {
    const results = await Promise.all(PROVIDERS.map(async (p) => {
        const running = await probe(p.type === "ollama" ? `${p.baseUrl}/api/tags` : `${p.baseUrl}/v1/models`);
        if (!running)
            return [];
        return p.type === "ollama" ? fetchOllamaModels(p.baseUrl) : fetchLlamaServerModels(p.baseUrl);
    }));
    const models = results.flat();
    // Update routing table
    modelRouteTable.clear();
    for (const m of models) {
        modelRouteTable.set(m.id, m.backendUrl);
    }
    return models;
}
// ---------------------------------------------------------------------------
// Environment → webui_settings
// ---------------------------------------------------------------------------
/** Load KEY=VALUE pairs from ~/.avis/env into process.env (launchd-safe). */
function loadEnvFile() {
    const envPath = nodePath.join(os.homedir(), ".avis", "env");
    try {
        const content = fs.readFileSync(envPath, "utf-8");
        for (const line of content.split("\n")) {
            const trimmed = line.trim();
            if (!trimmed || trimmed.startsWith("#"))
                continue;
            const eq = trimmed.indexOf("=");
            if (eq < 1)
                continue;
            const key = trimmed.slice(0, eq).trim();
            const val = trimmed.slice(eq + 1).trim();
            // Don't overwrite vars already set (e.g. from shell)
            if (!process.env[key]) {
                process.env[key] = val;
            }
        }
    }
    catch {
        // File doesn't exist or unreadable — that's fine
    }
}
// Load env file once at import time
loadEnvFile();
/**
 * Env vars prefixed with AVIS_ are exposed to the web UI via webui_settings.
 * Additional well-known keys (HF_TOKEN, HUME_*, OLLAMA_*) are included when set.
 * Secrets are masked so the UI can show "configured" state without leaking full tokens.
 */
function collectWebuiEnv() {
    const out = {};
    // Well-known keys to surface (value masked)
    const MASKED_KEYS = [
        "HF_TOKEN",
        "HUME_API_KEY",
        "HUME_SECRET_KEY",
        "HUME_CONFIG_ID",
        "OLLAMA_API_KEY",
        "MODAL_TOKEN_ID",
        "MODAL_TOKEN_SECRET",
    ];
    // Boolean presence flags for secrets
    for (const key of MASKED_KEYS) {
        const val = process.env[key];
        if (val) {
            out[`env.${key}`] = true;
        }
    }
    // Non-secret AVIS_ vars (pass value through)
    const SECRET_PATTERN = /KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL/i;
    for (const [key, val] of Object.entries(process.env)) {
        if (!key.startsWith("AVIS_") || !val)
            continue;
        if (SECRET_PATTERN.test(key)) {
            out[`env.${key}`] = true;
        }
        else {
            out[`env.${key}`] = val;
        }
    }
    // Useful runtime info
    out["env.OLLAMA_FLASH_ATTENTION"] = process.env.OLLAMA_FLASH_ATTENTION === "1";
    out["env.NODE_ENV"] = process.env.NODE_ENV ?? "production";
    return out;
}
// ---------------------------------------------------------------------------
// Response builders
// ---------------------------------------------------------------------------
function buildModelsResponse(models) {
    const now = Math.floor(Date.now() / 1000);
    return {
        object: "list",
        data: models.map((m) => ({
            id: m.id,
            name: m.name,
            object: "model",
            owned_by: m.provider,
            created: now,
            in_cache: true,
            path: "",
            status: { value: m.status },
            aliases: [],
            tags: [],
            meta: null,
        })),
        models: models.map((m) => ({
            name: m.name,
            model: m.id,
            description: `${m.provider} model`,
            capabilities: ["completion"],
            details: {
                family: m.details?.family ?? m.provider,
                parameter_size: m.details?.parameter_size ?? "",
                quantization_level: m.details?.quantization_level ?? "",
            },
        })),
    };
}
function buildPropsResponse() {
    return {
        role: "router",
        total_slots: 4,
        model_path: "",
        chat_template: "",
        bos_token: "",
        eos_token: "",
        build_info: "avis-gateway",
        modalities: { vision: false, audio: false },
        webui_settings: collectWebuiEnv(),
        default_generation_settings: {
            id: 0,
            id_task: -1,
            n_ctx: 32768,
            speculative: false,
            is_processing: false,
            params: {
                n_predict: -1,
                seed: 4294967295,
                temperature: 0.7,
                dynatemp_range: 0.0,
                dynatemp_exponent: 1.0,
                top_k: 40,
                top_p: 0.95,
                min_p: 0.05,
                top_n_sigma: -1,
                xtc_probability: 0.0,
                xtc_threshold: 0.1,
                typ_p: 1.0,
                repeat_last_n: 64,
                repeat_penalty: 1.0,
                presence_penalty: 0.0,
                frequency_penalty: 0.0,
                dry_multiplier: 0.0,
                dry_base: 1.75,
                dry_allowed_length: 2,
                dry_penalty_last_n: -1,
                dry_sequence_breakers: ["\n", ":", '"', "*"],
                mirostat: 0,
                mirostat_tau: 5.0,
                mirostat_eta: 0.1,
                stop: [],
                max_tokens: -1,
                n_keep: 0,
                n_discard: 0,
                ignore_eos: false,
                stream: true,
                logit_bias: [],
                n_probs: 0,
                min_keep: 0,
                grammar: "",
                grammar_lazy: false,
                grammar_triggers: [],
                preserved_tokens: [],
                chat_format: "chatml",
                reasoning_format: "none",
                reasoning_in_content: false,
                generation_prompt: "",
                samplers: ["penalties", "dry", "top_n_sigma", "top_k", "typ_p", "top_p", "min_p", "xtc", "temperature"],
                backend_sampling: false,
                "speculative.n_max": 16,
                "speculative.n_min": 5,
                "speculative.p_min": 0.9,
                timings_per_token: false,
                post_sampling_probs: false,
                lora: [],
            },
            prompt: "",
            next_token: {
                has_next_token: false,
                has_new_line: false,
                n_remain: 0,
                n_decoded: 0,
                stopping_word: "",
            },
        },
    };
}
// ---------------------------------------------------------------------------
// Completion proxy
// ---------------------------------------------------------------------------
async function proxyCompletion(modelId, body, incoming, res) {
    const backendUrl = modelRouteTable.get(modelId);
    if (!backendUrl) {
        res.writeHead(404, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ error: { message: `Model "${modelId}" not found. Run /scan in Avis to discover models.` } }));
        return;
    }
    // Forward auth header if present
    const headers = {
        "Content-Type": "application/json",
    };
    if (incoming.headers.authorization) {
        headers.Authorization = incoming.headers.authorization;
    }
    try {
        const upstream = await fetch(`${backendUrl}/v1/chat/completions`, {
            method: "POST",
            headers,
            body,
            duplex: "half",
        });
        res.writeHead(upstream.status, {
            "Content-Type": upstream.headers.get("content-type") ?? "application/json",
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
        });
        if (!upstream.body) {
            res.end();
            return;
        }
        const reader = upstream.body.getReader();
        while (true) {
            const { done, value } = await reader.read();
            if (done)
                break;
            res.write(value);
        }
        res.end();
    }
    catch (err) {
        if (!res.headersSent) {
            res.writeHead(502, { "Content-Type": "application/json" });
        }
        res.end(JSON.stringify({ error: { message: `Gateway error: ${err instanceof Error ? err.message : String(err)}` } }));
    }
}
// ---------------------------------------------------------------------------
// Request handler (plugged into the daemon HTTP server)
// ---------------------------------------------------------------------------
const CORS_HEADERS = {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
};
export async function handleGatewayRequest(req, res) {
    const url = req.url ?? "/";
    const method = req.method ?? "GET";
    // CORS preflight
    if (method === "OPTIONS") {
        res.writeHead(204, CORS_HEADERS);
        res.end();
        return true;
    }
    // Add CORS to all responses
    for (const [k, v] of Object.entries(CORS_HEADERS)) {
        res.setHeader(k, v);
    }
    // GET /props
    if (method === "GET" && (url === "/props" || url.startsWith("/props?"))) {
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify(buildPropsResponse()));
        return true;
    }
    // GET /v1/models or GET /models
    if (method === "GET" &&
        (url === "/v1/models" || url === "/models" || url.startsWith("/v1/models?") || url.startsWith("/models?"))) {
        const models = await aggregateModels();
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify(buildModelsResponse(models)));
        return true;
    }
    // POST /v1/chat/completions
    if (method === "POST" && url === "/v1/chat/completions") {
        const chunks = [];
        req.on("data", (chunk) => chunks.push(chunk));
        req.on("end", async () => {
            const body = Buffer.concat(chunks).toString("utf-8");
            let modelId = "";
            try {
                modelId = JSON.parse(body).model ?? "";
            }
            catch {
                // leave empty
            }
            await proxyCompletion(modelId, body, req, res);
        });
        return true;
    }
    return false; // not handled by gateway
}
//# sourceMappingURL=model-gateway.js.map