"""
Brynq Runtime — Local API Server.

The desktop app (app/) talks to this server on localhost:8003.
This is the user-facing interface for the runtime. It:
    1. Detects available local models (Ollama).
    2. Calls the cloud to get execution plans.
    3. Executes plans locally (user's machine, user's keys).
    4. Returns results to the desktop app.

Endpoints:
    POST /chat                      — Simple single-model chat (with chat history)
    POST /chain                     — Multi-model chain execution
    POST /v1/chain                  — Multi-model chain (v1 API, accepts strategy)
    POST /chats                     — Create a new chat conversation
    GET  /chats                     — List all chats (sorted by updated_at desc)
    GET  /chats/{id}                — Get full chat with messages
    PUT  /chats/{id}                — Update chat title/project_id
    DELETE /chats/{id}              — Delete a chat
    GET  /models                    — List available models (local + cloud BYOK)
    GET  /agents                    — List installed + marketplace agents
    POST /agents/install            — Install an agent (MCP, local, or remote)
    DELETE /agents/{agent_id}       — Uninstall an agent
    GET  /agents/installed          — List all installed agents
    POST /agents/{agent_id}/start   — Start an MCP agent (on-demand)
    GET  /health                    — Lightweight health check (status, version, uptime)
    GET  /version                   — Version info (version, runtime name, API version)
    GET  /stats                     — Runtime stats (request count, uptime, model count)
    GET  /status                    — Runtime health (Ollama, cloud, keys)
    POST /keys                      — Store an API key locally
    GET  /keys                      — List stored keys (masked)
    DELETE /keys/{provider}         — Remove a key
    POST /email/configure            — Save SMTP email configuration
    POST /auth/login/{provider}     — Start OAuth flow, returns auth URL
    GET  /auth/callback/{provider}  — OAuth redirect handler
    GET  /auth/connections          — List connected providers with status
    POST /auth/logout/{provider}    — Disconnect a provider
    GET  /rag/list                  — List indexed RAG files
    POST /rag/index                 — Index a file for RAG
    DELETE /rag/files               — Remove indexed file
    GET  /rag/search                — Search RAG chunks by query
    GET  /webhooks                    — List all registered webhooks
    POST /webhooks/register          — Register a webhook to trigger chains
    POST /webhooks/{id}/trigger      — Trigger a webhook (Bearer auth)
    DELETE /webhooks/{id}             — Delete a webhook by ID
    POST /schedules                  — Create a scheduled chain execution
    GET  /templates                  — List all templates (optional ?tag= filter)
    POST /templates                  — Save a new chain template
    POST /v1/debate                 — Multi-model debate (models argue in rounds)
    POST /v1/refine                 — Iterative refinement (each model improves previous output)
    POST /v1/single                 — Send prompt to one model, get response
    POST /v1/fan-out                — Fan-out: send prompt to all models concurrently
    GET  /v1/strategies             — List available chain strategies with metadata
    GET  /v1/history                — Return last N chain history entries
    DELETE /v1/history              — Truncate chain history file
    POST /batch                     — Start batch processing (background job)
    GET  /batch/{job_id}             — Check background batch job status

Zero proprietary logic here. This is a thin executor.
Runs on port 8003 — separate from cloud API (8002 dev) and web app (3000).
"""

from __future__ import annotations

import asyncio
import json
import logging
import time
import traceback
import urllib.error
import urllib.request
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

from runtime.auth.oauth_manager import OAuthManager, OAuthTokenError
from runtime.cloud_client import CloudClient, CloudError, CloudRejected, CloudUnavailable
from runtime.config import RuntimeConfig, load_config
from runtime.version import get_version

logger = logging.getLogger(__name__)

_START_TIME = time.time()
_request_count = 0

# ── Batch job tracking ────────────────────────────────────────────────
# Maps job_id → BatchProgress instance for background batch tasks.
_batch_jobs: dict[str, Any] = {}

# ── Ollama integration ─────────────────────────────────────────────────


def _ollama_list_models(ollama_url: str, timeout: float = 5.0) -> list[dict[str, Any]]:
    """Query Ollama for locally available models.

    Returns:
        List of model dicts with "name", "size", "details" fields.
        Empty list if Ollama is unreachable.
    """
    try:
        req = urllib.request.Request(f"{ollama_url}/api/tags", method="GET")
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            return data.get("models", [])
    except Exception:
        return []


def _ollama_chat(
    ollama_url: str,
    model: str,
    prompt: str,
    system_prompt: str | None = None,
    temperature: float | None = None,
    timeout: float = 120.0,
    history: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
    """Send a chat completion request to Ollama.

    Returns:
        Dict with "content", "model", "tokens", "duration_ms".

    Raises:
        HTTPException: If Ollama is unreachable or the model call fails.
    """
    messages: list[dict[str, str]] = []
    if system_prompt:
        messages.append({"role": "system", "content": system_prompt})
    # Include conversation history for memory
    if history:
        messages.extend(history)
    messages.append({"role": "user", "content": prompt})

    body: dict[str, Any] = {"model": model, "messages": messages, "stream": False}
    if temperature is not None:
        body["options"] = {"temperature": temperature}

    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(
        f"{ollama_url}/api/chat",
        data=data,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            result = json.loads(resp.read().decode("utf-8"))
            message = result.get("message", {})
            return {
                "content": message.get("content", ""),
                "model": model,
                "tokens": result.get("eval_count", 0),
                "duration_ms": int(result.get("total_duration", 0) / 1_000_000),
            }
    except urllib.error.URLError as e:
        raise HTTPException(status_code=502, detail=f"Ollama unreachable: {e.reason}")
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Ollama error: {e}")


def _ollama_is_running(ollama_url: str) -> bool:
    """Check if Ollama is reachable."""
    try:
        req = urllib.request.Request(f"{ollama_url}/api/tags", method="GET")
        with urllib.request.urlopen(req, timeout=3.0):
            return True
    except Exception:
        return False


# ── Local key management ───────────────────────────────────────────────

VALID_PROVIDERS = frozenset({
    "claude", "gemini", "gpt", "openai", "anthropic", "google", "ollama", "lmstudio",
})


def _keys_file(data_dir: Path) -> Path:
    return data_dir / "keys.json"


def _read_keys(data_dir: Path) -> dict[str, str]:
    """Read stored API keys from disk."""
    path = _keys_file(data_dir)
    if not path.exists():
        return {}
    try:
        return json.loads(path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}


def _write_keys(data_dir: Path, keys: dict[str, str]) -> None:
    """Write API keys to disk."""
    data_dir.mkdir(parents=True, exist_ok=True)
    _keys_file(data_dir).write_text(json.dumps(keys, indent=2), "utf-8")


def _mask_key(key: str) -> str:
    """Mask an API key for display: show first 4 and last 4 chars."""
    if len(key) <= 12:
        return key[:4] + "..." + key[-2:]
    return key[:4] + "..." + key[-4:]


# ── Project storage ────────────────────────────────────────────────────


def _projects_file(data_dir: Path) -> Path:
    return data_dir / "projects.json"


def _read_projects(data_dir: Path) -> list[dict[str, Any]]:
    """Read the projects list from disk."""
    path = _projects_file(data_dir)
    if not path.exists():
        return []
    try:
        data = json.loads(path.read_text("utf-8"))
        return data.get("projects", [])
    except (json.JSONDecodeError, OSError):
        return []


def _write_projects(data_dir: Path, projects: list[dict[str, Any]]) -> None:
    """Write the projects list to disk."""
    data_dir.mkdir(parents=True, exist_ok=True)
    _projects_file(data_dir).write_text(
        json.dumps({"projects": projects}, indent=2), "utf-8",
    )


def _find_project(projects: list[dict[str, Any]], project_id: str) -> dict[str, Any] | None:
    """Find a project by ID. Returns None if not found."""
    for proj in projects:
        if proj["id"] == project_id:
            return proj
    return None


def _now_iso() -> str:
    """Return current UTC time as ISO 8601 string."""
    return datetime.now(timezone.utc).isoformat()


# ── Chat history storage ──────────────────────────────────────────────


def _chats_dir(data_dir: Path) -> Path:
    """Return the ~/.brynq/chats/ directory, creating it if needed."""
    d = data_dir / "chats"
    d.mkdir(parents=True, exist_ok=True)
    return d


def _chat_index_path(data_dir: Path) -> Path:
    return _chats_dir(data_dir) / "index.json"


def _chat_file_path(data_dir: Path, chat_id: str) -> Path:
    return _chats_dir(data_dir) / f"{chat_id}.json"


def _read_chat_index(data_dir: Path) -> list[dict[str, Any]]:
    """Read the chat index (lightweight list of all chats)."""
    path = _chat_index_path(data_dir)
    if not path.exists():
        return []
    try:
        return json.loads(path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError):
        return []


def _write_chat_index(data_dir: Path, index: list[dict[str, Any]]) -> None:
    """Write the chat index to disk."""
    _chat_index_path(data_dir).write_text(json.dumps(index, indent=2), "utf-8")


def _read_chat(data_dir: Path, chat_id: str) -> dict[str, Any] | None:
    """Read a full chat object from its JSON file. Returns None if not found."""
    path = _chat_file_path(data_dir, chat_id)
    if not path.exists():
        return None
    try:
        return json.loads(path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError):
        return None


def _write_chat(data_dir: Path, chat: dict[str, Any]) -> None:
    """Write a full chat object to its JSON file."""
    path = _chat_file_path(data_dir, chat["id"])
    path.write_text(json.dumps(chat, indent=2), "utf-8")


def _delete_chat_file(data_dir: Path, chat_id: str) -> None:
    """Delete a chat's JSON file from disk."""
    path = _chat_file_path(data_dir, chat_id)
    if path.exists():
        path.unlink()


def _create_chat(
    data_dir: Path,
    title: str | None = None,
    project_id: str | None = None,
    chat_id: str | None = None,
) -> dict[str, Any]:
    """Create a new chat object, persist it, and update the index.

    Returns:
        The newly created chat dict.
    """
    now = _now_iso()
    if not chat_id:
        chat_id = f"chat_{uuid.uuid4().hex[:12]}"
    chat: dict[str, Any] = {
        "id": chat_id,
        "title": title,
        "project_id": project_id,
        "messages": [],
        "created_at": now,
        "updated_at": now,
    }
    _write_chat(data_dir, chat)

    # Add to index
    index = _read_chat_index(data_dir)
    index.append({
        "id": chat_id,
        "title": title,
        "project_id": project_id,
        "last_message": None,
        "updated_at": now,
    })
    _write_chat_index(data_dir, index)

    return chat


def _update_chat_index_entry(
    data_dir: Path,
    chat_id: str,
    **updates: Any,
) -> None:
    """Update fields of a specific chat in the index."""
    index = _read_chat_index(data_dir)
    for entry in index:
        if entry["id"] == chat_id:
            entry.update(updates)
            break
    _write_chat_index(data_dir, index)


def _auto_title(message: str) -> str:
    """Generate an auto-title from the first user message (first 50 chars)."""
    title = message.strip().replace("\n", " ")
    if len(title) > 50:
        title = title[:50] + "..."
    return title


# ── Retry logic for transient cloud errors ─────────────────────────────

_RETRYABLE_CODES = {"429", "503", "529"}  # rate limit, unavailable, overloaded
_MAX_RETRIES = 3
_RETRY_DELAYS = [1.0, 2.0, 4.0]  # exponential backoff


def _call_with_retry(
    fn, *args, **kwargs
) -> tuple[str, int]:
    """Call a cloud LLM function with exponential backoff on transient errors.

    Retries on 429 (rate limit), 503 (unavailable), 529 (overloaded).
    Fails immediately on 401 (auth), 404 (not found), and other errors.
    """
    last_error: Exception | None = None
    for attempt in range(_MAX_RETRIES + 1):
        try:
            return fn(*args, **kwargs)
        except urllib.error.HTTPError as e:
            code = str(e.code)
            if code in _RETRYABLE_CODES and attempt < _MAX_RETRIES:
                delay = _RETRY_DELAYS[attempt]
                logger.warning(
                    "Cloud API returned %s, retrying in %.1fs (attempt %d/%d)",
                    code, delay, attempt + 1, _MAX_RETRIES,
                )
                time.sleep(delay)
                last_error = e
                continue
            raise
        except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
            if attempt < _MAX_RETRIES:
                delay = _RETRY_DELAYS[attempt]
                logger.warning(
                    "Cloud API connection error, retrying in %.1fs (attempt %d/%d): %s",
                    delay, attempt + 1, _MAX_RETRIES, e,
                )
                time.sleep(delay)
                last_error = e
                continue
            raise
    # Should never reach here, but just in case
    raise last_error or RuntimeError("Retry exhausted")


# ── Cloud LLM API calls (BYOK) ────────────────────────────────────────


def _call_cloud_api(
    backend: str,
    model: str,
    prompt: str,
    config: dict[str, Any],
    keys: dict[str, str],
    oauth_manager: OAuthManager | None = None,
) -> tuple[str, int]:
    """Call a cloud LLM API using OAuth subscription (preferred) or BYOK key (fallback).

    Credential priority:
        1. OAuth token (routes through user's subscription — no extra charges)
        2. BYOK API key (pay-per-token — only if OAuth not available)

    Returns:
        Tuple of (response_text, token_count).

    Raises:
        HTTPException: If no credentials are available or the call fails.
    """
    provider_map: dict[str, str] = {
        "claude": "anthropic",
        "openai": "openai",
        "gpt": "openai",
        "gemini": "google",
    }
    provider = provider_map.get(backend)
    if not provider:
        raise HTTPException(status_code=400, detail=f"Unsupported backend: {backend}")

    # Priority 1: OAuth token (subscription — no extra per-token charges)
    oauth_token: str | None = None
    if oauth_manager is not None:
        try:
            oauth_token = oauth_manager.get_token_sync(provider)
        except Exception:
            logger.debug("OAuth token not available for %s, trying BYOK", provider)

    # Priority 2: BYOK API key (pay-per-token fallback)
    api_key = keys.get(provider)

    if not oauth_token and not api_key:
        provider_display = {"anthropic": "Claude", "google": "Gemini", "openai": "ChatGPT"}.get(provider, provider)
        raise HTTPException(
            status_code=400,
            detail=f"No credentials for {provider_display}. Connect your account or add an API key in Settings.",
        )

    try:
        credential = oauth_token or api_key
        if provider == "anthropic":
            return _call_with_retry(_call_anthropic, credential, model, prompt, config)
        elif provider == "openai":
            return _call_with_retry(_call_openai, credential, model, prompt, config)
        elif provider == "google":
            auth = "oauth" if oauth_token else "api_key"
            return _call_with_retry(_call_google, credential, model, prompt, config, auth_type=auth)
        else:
            raise HTTPException(status_code=400, detail=f"Unsupported provider: {provider}")
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=502, detail=f"{provider} API error: {e}")


def _call_anthropic(
    api_key: str, model: str, prompt: str, config: dict[str, Any],
) -> tuple[str, int]:
    """Call Anthropic Messages API."""
    body: dict[str, Any] = {
        "model": model or "claude-sonnet-4-20250514",
        "max_tokens": config.get("max_tokens", 4096),
        "messages": [{"role": "user", "content": prompt}],
    }
    if config.get("system_prompt"):
        body["system"] = config["system_prompt"]
    if config.get("temperature") is not None:
        body["temperature"] = config["temperature"]

    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(
        "https://api.anthropic.com/v1/messages",
        data=data,
        headers={
            "Content-Type": "application/json",
            "x-api-key": api_key,
            "anthropic-version": "2023-06-01",
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=120) as resp:
        result = json.loads(resp.read().decode("utf-8"))
        blocks = result.get("content", [])
        text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
        usage = result.get("usage", {})
        tokens = usage.get("input_tokens", 0) + usage.get("output_tokens", 0)
        return text, tokens


def _call_openai(
    api_key: str, model: str, prompt: str, config: dict[str, Any],
) -> tuple[str, int]:
    """Call OpenAI Chat Completions API."""
    messages: list[dict[str, str]] = []
    if config.get("system_prompt"):
        messages.append({"role": "system", "content": config["system_prompt"]})
    messages.append({"role": "user", "content": prompt})

    body: dict[str, Any] = {"model": model or "gpt-4o", "messages": messages}
    if config.get("temperature") is not None:
        body["temperature"] = config["temperature"]
    if config.get("max_tokens"):
        body["max_tokens"] = config["max_tokens"]

    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(
        "https://api.openai.com/v1/chat/completions",
        data=data,
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}",
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=120) as resp:
        result = json.loads(resp.read().decode("utf-8"))
        choices = result.get("choices", [])
        text = choices[0]["message"]["content"] if choices else ""
        tokens = result.get("usage", {}).get("total_tokens", 0)
        return text, tokens


def _call_google(
    credential: str, model: str, prompt: str, config: dict[str, Any],
    auth_type: str = "api_key",
) -> tuple[str, int]:
    """Call Google Generative Language API.

    Auth:
    - OAuth (auth_type="oauth"):   Authorization: Bearer header (uses subscription)
    - API key (auth_type="api_key"): ?key= query parameter (pay-per-token)
    """
    model = model or "gemini-2.0-flash"
    base_url = (
        f"https://generativelanguage.googleapis.com/v1beta"
        f"/models/{model}:generateContent"
    )

    # OAuth routes through subscription, API key routes to billing project
    headers: dict[str, str] = {"Content-Type": "application/json"}
    if auth_type == "oauth":
        url = base_url
        headers["Authorization"] = f"Bearer {credential}"
    else:
        url = f"{base_url}?key={credential}"

    body: dict[str, Any] = {"contents": [{"parts": [{"text": prompt}]}]}

    # Use systemInstruction field instead of prepending to prompt
    if config.get("system_prompt"):
        body["systemInstruction"] = {"parts": [{"text": config["system_prompt"]}]}

    if config.get("temperature") is not None:
        body["generationConfig"] = {"temperature": config["temperature"]}

    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(url, data=data, headers=headers, method="POST")
    with urllib.request.urlopen(req, timeout=120) as resp:
        result = json.loads(resp.read().decode("utf-8"))
        candidates = result.get("candidates", [])
        text = ""
        if candidates:
            parts = candidates[0].get("content", {}).get("parts", [])
            text = "".join(p.get("text", "") for p in parts)
        tokens = result.get("usageMetadata", {}).get("totalTokenCount", 0)
        return text, tokens


def _call_agent(endpoint: str, prompt: str, config: dict[str, Any]) -> str:
    """Call a local or remote agent endpoint.

    Returns:
        Agent response text, or an error message on failure.
    """
    if not endpoint.startswith("http"):
        endpoint = f"http://{endpoint}"

    body = {"input": prompt, "config": config}
    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(
        endpoint, data=data, headers={"Content-Type": "application/json"}, method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            result = json.loads(resp.read().decode("utf-8"))
            return result.get("output", result.get("content", json.dumps(result)))
    except Exception as e:
        return f"Agent error: {e}"


# ── Plan execution ─────────────────────────────────────────────────────


def _execute_plan_steps(
    plan: dict[str, Any],
    config: RuntimeConfig,
    keys: dict[str, str],
    oauth_manager: OAuthManager | None = None,
) -> dict[str, Any]:
    """Execute an execution plan step by step.

    Routes each step to the correct backend (Ollama, cloud API, agent).
    Uses OAuth subscription tokens when available, BYOK keys as fallback.
    Captures outputs per step and builds metadata for cloud reporting.

    Returns:
        Dict with "outputs" (list of step result dicts) and
        "metadata" (list of timing/token dicts for reporting).
    """
    steps = plan.get("steps", [])
    step_outputs: dict[str, str] = {}  # step_id -> output text
    results: list[dict[str, Any]] = []
    metadata: list[dict[str, Any]] = []
    original_input = plan.get("original_input", "")

    for step in steps:
        step_id = step.get("step_id", "")
        step_type = step.get("type", "")
        backend = step.get("backend", "")
        model = step.get("model", "")
        prompt = step.get("prompt", "")
        context_refs = step.get("context_refs", [])
        step_config = step.get("config", {})

        # Resolve context references from prior step outputs
        context_parts: list[str] = []
        for ref in context_refs:
            if ref == "original_input":
                context_parts.append(original_input)
            elif ref in step_outputs:
                context_parts.append(step_outputs[ref])

        full_prompt = "\n\n".join(context_parts + [prompt]) if context_parts else prompt

        start = time.time()
        output = ""
        token_count = 0
        success = True
        error_msg: str | None = None

        try:
            if step_type == "local":
                result = _ollama_chat(
                    config.ollama_url, model, full_prompt,
                    system_prompt=step_config.get("system_prompt"),
                    temperature=step_config.get("temperature"),
                    timeout=config.ollama_timeout,
                )
                output = result["content"]
                token_count = result.get("tokens", 0)

            elif step_type == "cloud":
                output, token_count = _call_cloud_api(
                    backend, model, full_prompt, step_config, keys,
                    oauth_manager=oauth_manager,
                )

            elif step_type in ("local_agent", "remote_agent"):
                endpoint = step.get("endpoint", "")
                output = _call_agent(endpoint, full_prompt, step_config)

            else:
                error_msg = f"Unknown step type: {step_type}"
                success = False

        except HTTPException:
            raise
        except Exception as e:
            error_msg = str(e)
            success = False
            logger.error("Step %s failed: %s", step_id, e)

        duration_ms = int((time.time() - start) * 1000)
        step_outputs[step_id] = output

        results.append({
            "step_id": step_id,
            "content": output,
            "model": model,
            "success": success,
            "error": error_msg,
        })
        metadata.append({
            "step_id": step_id,
            "duration_ms": duration_ms,
            "token_count": token_count,
            "success": success,
        })

    return {"outputs": results, "metadata": metadata}


# ── Debate model caller ────────────────────────────────────────────────


def _debate_call_model(
    model_id: str,
    prompt: str,
    config: RuntimeConfig,
    keys: dict[str, str],
    oauth_manager: OAuthManager | None = None,
) -> str:
    """Route a single model call for the debate endpoint.

    Resolves model_id to the correct backend:
        - ``ollama:*``   → local Ollama
        - ``claude-*``   → Anthropic cloud API
        - ``gemini-*``   → Google cloud API
        - ``gpt-*``      → OpenAI cloud API

    Returns:
        The model's text response.
    """
    if model_id.startswith("ollama:"):
        ollama_model = model_id[len("ollama:"):]
        result = _ollama_chat(config.ollama_url, ollama_model, prompt, timeout=config.ollama_timeout)
        return result["content"]

    # Determine cloud backend from model name prefix
    if model_id.startswith("claude") or model_id.startswith("anthropic"):
        backend = "claude"
    elif model_id.startswith("gemini"):
        backend = "gemini"
    elif model_id.startswith("gpt") or model_id.startswith("o3") or model_id.startswith("o1"):
        backend = "gpt"
    else:
        raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")

    text, _tokens = _call_cloud_api(backend, model_id, prompt, {}, keys, oauth_manager=oauth_manager)
    return text


# ── Available models helper ────────────────────────────────────────────


def _collect_available_models(
    config: RuntimeConfig,
    keys: dict[str, str],
    oauth_manager: OAuthManager | None = None,
) -> tuple[list[dict[str, Any]], list[str]]:
    """Detect ALL available models on the user's machine.

    Discovery priority per provider:
        1. OAuth subscription token (Google — free via subscription)
        2. CLI installed on PATH (claude, gemini — uses their subscription)
        3. BYOK API key in local vault (pay-per-token fallback)
        4. Ollama models (always free, always local)

    Returns:
        Tuple of (local_models_raw, model_id_list).
    """
    import shutil

    local_models = _ollama_list_models(config.ollama_url)
    model_names = [f"ollama:{m['name']}" for m in local_models]

    # Google: OAuth subscription (preferred) or API key
    google_available = False
    if oauth_manager is not None:
        try:
            token = oauth_manager.get_token_sync("google")
            if token:
                google_available = True
        except Exception:
            pass
    if not google_available and "google" in keys:
        google_available = True
    if google_available:
        model_names.append("gemini-2.0-flash")
        model_names.append("gemini-2.5-pro")

    # Claude: CLI subscription (preferred) or API key
    claude_available = False
    if shutil.which("claude"):
        claude_available = True  # User has Claude Code installed = active subscription
    if not claude_available and "anthropic" in keys:
        claude_available = True
    if claude_available:
        model_names.append("claude-sonnet-4-20250514")

    # ChatGPT: API key only (no CLI detection, no OAuth yet)
    if "openai" in keys:
        model_names.append("gpt-4o")

    return local_models, model_names


# ── FastAPI Application ────────────────────────────────────────────────


def create_app(config: RuntimeConfig | None = None) -> FastAPI:
    """Create the runtime's local FastAPI server.

    Called by the desktop app (Electron/Tauri) on localhost:8003.

    Args:
        config: Runtime configuration. If None, loaded from environment.

    Returns:
        Configured FastAPI instance.
    """
    if config is None:
        config = load_config()

    app = FastAPI(
        title="Brynq Runtime",
        description="Local AI chain executor",
        version="0.1.0",
        docs_url="/docs" if config.debug else None,
        redoc_url=None,
    )

    app.add_middleware(
        CORSMiddleware,
        allow_origins=[
            "http://localhost:3000",
            "http://localhost:5173",
            "http://localhost:5174",
            "http://127.0.0.1:3000",
            "http://127.0.0.1:5173",
            "http://127.0.0.1:5174",
            "https://brynq.ai",
            "https://www.brynq.ai",
            "tauri://localhost",
        ],
        allow_credentials=True,
        allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
        allow_headers=["Content-Type", "Authorization"],
    )

    @app.middleware("http")
    async def limit_request_body(request: Request, call_next):
        content_length = request.headers.get("content-length")
        if content_length is not None and int(content_length) > 10_485_760:
            return JSONResponse(
                status_code=413,
                content={"detail": "Request body too large"},
            )
        return await call_next(request)

    @app.middleware("http")
    async def add_nosniff_header(request: Request, call_next):
        response = await call_next(request)
        response.headers["X-Content-Type-Options"] = "nosniff"
        return response

    @app.middleware("http")
    async def log_requests(request: Request, call_next):
        global _request_count
        _request_count += 1
        start = time.perf_counter()
        response = await call_next(request)
        duration_ms = (time.perf_counter() - start) * 1000
        logger.info(
            "%s %s %d %.1fms",
            request.method,
            request.url.path,
            response.status_code,
            duration_ms,
        )
        return response

    @app.exception_handler(Exception)
    async def global_exception_handler(request: Request, exc: Exception):
        tb = traceback.format_exception(type(exc), exc, exc.__traceback__)
        logger.error(
            "Unhandled exception on %s %s:\n%s",
            request.method,
            request.url.path,
            "".join(tb),
        )
        return JSONResponse(
            status_code=500,
            content={"error": "internal_error", "detail": "An unexpected error occurred"},
        )

    cloud = CloudClient(config)
    oauth = OAuthManager()
    app.state.config = config
    app.state.cloud = cloud
    app.state.oauth = oauth

    # ── Chat history CRUD ────────────────────────────────────────

    @app.post("/chats")
    async def create_chat_endpoint(request: Request) -> JSONResponse:
        """Create a new chat conversation.

        Body (all optional): {"title": "...", "project_id": "proj_xyz"}
        Returns the newly created chat object.
        """
        body = await request.json()
        title = body.get("title")
        project_id = body.get("project_id")
        chat = _create_chat(config.data_dir, title=title, project_id=project_id)
        return JSONResponse(chat, status_code=201)

    @app.get("/chats")
    async def list_chats(limit: int = 20, offset: int = 0) -> JSONResponse:
        """List all chats from index.json, sorted by updated_at descending."""
        limit = max(1, min(limit, 100))
        offset = max(0, offset)
        index = _read_chat_index(config.data_dir)
        index.sort(key=lambda c: c.get("updated_at", ""), reverse=True)
        total = len(index)
        page = index[offset:offset + limit]
        return JSONResponse({"chats": page, "total": total, "limit": limit, "offset": offset, "count": len(page)})

    @app.get("/chats/{chat_id}")
    async def get_chat(chat_id: str) -> JSONResponse:
        """Get a full chat with all messages."""
        chat = _read_chat(config.data_dir, chat_id)
        if chat is None:
            raise HTTPException(status_code=404, detail=f"Chat {chat_id} not found")
        return JSONResponse(chat)

    @app.delete("/chats/{chat_id}")
    async def delete_chat(chat_id: str) -> JSONResponse:
        """Delete a chat and remove it from the index."""
        chat = _read_chat(config.data_dir, chat_id)
        if chat is None:
            raise HTTPException(status_code=404, detail=f"Chat {chat_id} not found")

        _delete_chat_file(config.data_dir, chat_id)

        # Remove from index
        index = _read_chat_index(config.data_dir)
        index = [e for e in index if e["id"] != chat_id]
        _write_chat_index(config.data_dir, index)

        return JSONResponse({"deleted": True, "id": chat_id})

    @app.put("/chats/{chat_id}")
    async def update_chat(chat_id: str, request: Request) -> JSONResponse:
        """Update a chat's title or project_id.

        Body: {"title": "...", "project_id": "..."}  (both optional)
        """
        chat = _read_chat(config.data_dir, chat_id)
        if chat is None:
            raise HTTPException(status_code=404, detail=f"Chat {chat_id} not found")

        body = await request.json()
        if "title" in body:
            chat["title"] = body["title"]
        if "project_id" in body:
            chat["project_id"] = body["project_id"]
        chat["updated_at"] = _now_iso()
        _write_chat(config.data_dir, chat)

        # Sync changes to index
        index_updates: dict[str, Any] = {"updated_at": chat["updated_at"]}
        if "title" in body:
            index_updates["title"] = body["title"]
        if "project_id" in body:
            index_updates["project_id"] = body["project_id"]
        _update_chat_index_entry(config.data_dir, chat_id, **index_updates)

        return JSONResponse(chat)

    # ── POST /chat ─────────────────────────────────────────────────────

    @app.post("/chat")
    async def chat(request: Request) -> JSONResponse:
        """Simple chat: detect models, get plan from cloud, execute, return.

        Falls back to direct Ollama call if the cloud is unavailable.

        Accepts optional ``chat_id`` to persist messages into a chat history.
        When ``chat_id`` is provided, prior messages are loaded as context and
        the new user message + assistant response are appended to the chat file.
        When ``chat_id`` is omitted, a new chat is created automatically.
        """
        try:
            body = await request.json()
        except Exception:
            raise HTTPException(status_code=400, detail="Invalid JSON body")

        if not isinstance(body, dict):
            raise HTTPException(status_code=400, detail="Request body must be a JSON object")

        message = body.get("message", "")
        if not isinstance(message, str):
            raise HTTPException(status_code=400, detail="message must be a string")
        message = message.strip()
        if not message:
            raise HTTPException(status_code=400, detail="message is required")
        if len(message) > 100_000:
            raise HTTPException(status_code=400, detail="message exceeds 100,000 character limit")

        preferred_model = body.get("model")
        if preferred_model is not None and not isinstance(preferred_model, str):
            raise HTTPException(status_code=400, detail="model must be a string")

        system_prompt = body.get("system_prompt")
        if system_prompt is not None and not isinstance(system_prompt, str):
            raise HTTPException(status_code=400, detail="system_prompt must be a string")

        temperature = body.get("temperature")
        if temperature is not None:
            if not isinstance(temperature, (int, float)):
                raise HTTPException(status_code=400, detail="temperature must be a number")
            if not (0.0 <= temperature <= 2.0):
                raise HTTPException(status_code=400, detail="temperature must be between 0.0 and 2.0")

        max_tokens = body.get("max_tokens")
        if max_tokens is not None:
            if not isinstance(max_tokens, int) or isinstance(max_tokens, bool):
                raise HTTPException(status_code=400, detail="max_tokens must be an integer")
            if max_tokens < 1 or max_tokens > 1_000_000:
                raise HTTPException(status_code=400, detail="max_tokens must be between 1 and 1,000,000")

        history = body.get("history")  # Conversation history from frontend
        if history is not None:
            if not isinstance(history, list):
                raise HTTPException(status_code=400, detail="history must be an array")
            if len(history) > 1000:
                raise HTTPException(status_code=400, detail="history exceeds 1,000 message limit")
            for i, entry in enumerate(history):
                if not isinstance(entry, dict):
                    raise HTTPException(status_code=400, detail=f"history[{i}] must be an object")
                if "role" not in entry or "content" not in entry:
                    raise HTTPException(status_code=400, detail=f"history[{i}] must have 'role' and 'content'")
                if entry["role"] not in ("user", "assistant", "system"):
                    raise HTTPException(status_code=400, detail=f"history[{i}].role must be 'user', 'assistant', or 'system'")
                if not isinstance(entry["content"], str):
                    raise HTTPException(status_code=400, detail=f"history[{i}].content must be a string")

        chat_id = body.get("chat_id")
        if chat_id is not None and not isinstance(chat_id, str):
            raise HTTPException(status_code=400, detail="chat_id must be a string")

        # ── Resolve / create chat ──────────────────────────────────────
        chat_obj: dict[str, Any] | None = None
        if chat_id:
            chat_obj = _read_chat(config.data_dir, chat_id)
            if chat_obj is None:
                raise HTTPException(status_code=404, detail=f"Chat '{chat_id}' not found")
            # Use stored messages as history when caller didn't supply explicit history
            if history is None and chat_obj.get("messages"):
                history = [
                    {"role": m["role"], "content": m["content"]}
                    for m in chat_obj["messages"]
                ]
        else:
            # Auto-create a new chat
            chat_obj = _create_chat(config.data_dir)
            chat_id = chat_obj["id"]

        # Default system prompt
        if not system_prompt:
            model_name = preferred_model or "an AI model"
            system_prompt = (
                f"You are {model_name}, running locally on the user's computer through Brynq. "
                f"You are NOT a cloud service — you are running directly on their hardware, "
                f"which means their data stays private and never leaves their machine. "
                f"You are part of a team of AI models that can work together. "
                f"Be helpful, concise, and friendly. If asked about yourself, explain that "
                f"you run locally via Ollama on their PC through the Brynq platform."
            )

        keys = _read_keys(config.data_dir)
        local_models, model_names = _collect_available_models(config, keys, oauth_manager=oauth)

        if not model_names:
            raise HTTPException(
                status_code=503,
                detail="No models available. Start Ollama or add an API key.",
            )

        # ── Execute (cloud plan or local fallback) ─────────────────────
        response_text = ""
        response_model = ""
        response_tokens = 0
        response_duration = 0
        response_source = "plan"
        num_steps = 0

        try:
            plan_resp = cloud.get_plan(
                request=message,
                available_models=model_names,
                session_context={
                    "preferred_model": preferred_model,
                    "system_prompt": system_prompt,
                    "temperature": temperature,
                    "max_tokens": max_tokens,
                },
            )
            plan = plan_resp.plan

        except CloudError:
            # Cloud unavailable — fall back to direct local execution
            logger.warning("Cloud unavailable, falling back to direct Ollama chat")
            if not local_models:
                raise HTTPException(
                    status_code=503,
                    detail="Cloud unavailable and no local models found.",
                )
            target_model = preferred_model or local_models[0]["name"]
            result = _ollama_chat(
                config.ollama_url, target_model, message,
                system_prompt=system_prompt,
                temperature=temperature,
                timeout=config.ollama_timeout,
                history=history,
            )
            response_text = result["content"]
            response_model = result["model"]
            response_tokens = result.get("tokens", 0)
            response_duration = result.get("duration_ms", 0)
            response_source = "local_fallback"

        else:
            # Execute plan locally
            execution = _execute_plan_steps(plan, config, keys, oauth_manager=oauth)
            outputs = execution["outputs"]
            meta = execution["metadata"]

            total_ms = sum(m.get("duration_ms", 0) for m in meta)
            all_ok = all(m.get("success", False) for m in meta)

            try:
                cloud.report_results(
                    plan_id=plan.get("plan_id", ""),
                    steps=meta,
                    total_duration_ms=total_ms,
                    success=all_ok,
                )
            except CloudError:
                logger.debug("Failed to report results to cloud (non-fatal)")

            last = outputs[-1] if outputs else {}
            response_text = last.get("content", "")
            response_model = last.get("model", "")
            response_tokens = sum(m.get("token_count", 0) for m in meta)
            response_duration = total_ms
            num_steps = len(outputs)

        # ── Persist to chat history ────────────────────────────────────
        now = _now_iso()
        chat_obj["messages"].append({
            "role": "user",
            "content": message,
            "timestamp": now,
        })
        chat_obj["messages"].append({
            "role": "assistant",
            "content": response_text,
            "model": response_model,
            "timestamp": now,
        })
        chat_obj["updated_at"] = now

        # Auto-title: generate from first user message when title is still None
        if chat_obj["title"] is None:
            chat_obj["title"] = _auto_title(message)

        _write_chat(config.data_dir, chat_obj)

        # Update index with last_message preview + updated_at
        preview = message[:80] + ("..." if len(message) > 80 else "")
        _update_chat_index_entry(
            config.data_dir,
            chat_id,
            title=chat_obj["title"],
            last_message=preview,
            updated_at=now,
        )

        resp_data: dict[str, Any] = {
            "response": response_text,
            "content": response_text,
            "model": response_model,
            "tokens": response_tokens,
            "duration_ms": response_duration,
            "source": response_source,
            "chat_id": chat_id,
        }
        if num_steps:
            resp_data["steps"] = num_steps

        return JSONResponse(resp_data)

    # ── POST /chain ────────────────────────────────────────────────

    @app.post("/chain")
    async def chain(request: Request) -> JSONResponse:
        """Multi-model chain: get plan from cloud, execute all steps, return results.

        Unlike /chat, chains always require the cloud (no local fallback).
        """
        body = await request.json()
        prompt = body.get("prompt", body.get("request", ""))
        if not isinstance(prompt, str) or not prompt.strip():
            raise HTTPException(status_code=400, detail="prompt must be a non-empty string")
        chain_request = prompt.strip()

        models_pref = body.get("models", [])
        if not isinstance(models_pref, list) or not all(isinstance(m, str) for m in models_pref):
            raise HTTPException(status_code=400, detail="models must be a list of strings")

        keys = _read_keys(config.data_dir)
        local_models, model_names = _collect_available_models(config, keys, oauth_manager=oauth)

        if not model_names:
            raise HTTPException(
                status_code=503,
                detail="No models available. Start Ollama or add an API key.",
            )

        try:
            plan_resp = cloud.get_plan(
                request=chain_request,
                available_models=model_names,
                session_context={
                    "preferred_models": models_pref,
                    **(body.get("context") or {}),
                },
            )
        except CloudUnavailable as e:
            raise HTTPException(
                status_code=503,
                detail=f"Cloud unavailable: {e}. Chains require the Brynq cloud.",
            )
        except CloudRejected as e:
            raise HTTPException(status_code=400, detail=str(e))

        plan = plan_resp.plan
        execution = _execute_plan_steps(plan, config, keys, oauth_manager=oauth)
        outputs = execution["outputs"]
        meta = execution["metadata"]

        total_ms = sum(m.get("duration_ms", 0) for m in meta)
        all_ok = all(m.get("success", False) for m in meta)

        try:
            cloud.report_results(
                plan_id=plan_resp.plan_id,
                steps=meta,
                total_duration_ms=total_ms,
                success=all_ok,
            )
        except CloudError:
            logger.debug("Failed to report chain results to cloud (non-fatal)")

        return JSONResponse({
            "plan_id": plan_resp.plan_id,
            "steps": outputs,
            "total_duration_ms": total_ms,
            "success": all_ok,
            "source": "plan",
        })

    # ── POST /v1/chain ─────────────────────────────────────────────

    @app.post("/v1/chain")
    async def v1_chain(request: Request) -> JSONResponse:
        """Multi-model chain (v1 API): plan via cloud, execute locally, return results.

        Accepts:
            prompt:   str        — the user request to process
            models:   list[str]  — preferred model IDs
            strategy: str        — chain strategy (e.g. "sequential", "fan_out", "debate")

        Returns:
            result:           str        — final output from the last step
            steps:            list[dict] — per-step details
            total_latency_ms: int        — end-to-end wall time in ms
        """
        body = await request.json()

        prompt = body.get("prompt", "")
        if not isinstance(prompt, str) or not prompt.strip():
            raise HTTPException(status_code=400, detail="prompt must be a non-empty string")

        models = body.get("models", [])
        if not isinstance(models, list) or not all(isinstance(m, str) for m in models):
            raise HTTPException(status_code=400, detail="models must be a list of strings")

        strategy = body.get("strategy", "")
        if not isinstance(strategy, str) or not strategy.strip():
            raise HTTPException(status_code=400, detail="strategy must be a non-empty string")

        keys = _read_keys(config.data_dir)
        local_models, model_names = _collect_available_models(config, keys, oauth_manager=oauth)

        if not model_names:
            raise HTTPException(
                status_code=503,
                detail="No models available. Start Ollama or add an API key.",
            )

        try:
            plan_resp = cloud.get_plan(
                request=prompt.strip(),
                available_models=model_names,
                session_context={
                    "preferred_models": models,
                    "strategy": strategy.strip(),
                },
            )
        except CloudUnavailable as e:
            raise HTTPException(
                status_code=503,
                detail=f"Cloud unavailable: {e}. Chains require the Brynq cloud.",
            )
        except CloudRejected as e:
            raise HTTPException(status_code=400, detail=str(e))

        wall_start = time.time()
        plan = plan_resp.plan
        execution = _execute_plan_steps(plan, config, keys, oauth_manager=oauth)
        outputs = execution["outputs"]
        meta = execution["metadata"]
        total_ms = int((time.time() - wall_start) * 1000)

        all_ok = all(m.get("success", False) for m in meta)

        try:
            cloud.report_results(
                plan_id=plan_resp.plan_id,
                steps=meta,
                total_duration_ms=total_ms,
                success=all_ok,
            )
        except CloudError:
            logger.debug("Failed to report v1/chain results to cloud (non-fatal)")

        # Final result = last successful step's content, or empty string
        result = ""
        for step in reversed(outputs):
            if step.get("success"):
                result = step.get("content", "")
                break

        return JSONResponse({
            "result": result,
            "steps": [
                {
                    "step_id": s.get("step_id", ""),
                    "model": s.get("model", ""),
                    "content": s.get("content", ""),
                    "success": s.get("success", False),
                    "error": s.get("error"),
                    "duration_ms": m.get("duration_ms", 0),
                    "token_count": m.get("token_count", 0),
                }
                for s, m in zip(outputs, meta)
            ],
            "total_latency_ms": total_ms,
        })

    # ── GET /models ────────────────────────────────────────────────

    @app.get("/models")
    async def list_models() -> JSONResponse:
        """List all available models: local (Ollama) + cloud (OAuth/CLI/BYOK).

        Auto-detects every AI model available on the user's machine:
        - Ollama models (local, free)
        - Google Gemini (OAuth subscription or API key)
        - Claude (CLI subscription or API key)
        - ChatGPT (API key)
        """
        import shutil

        models: list[dict[str, Any]] = []

        # Local models via Ollama
        local_models = _ollama_list_models(config.ollama_url)
        for m in local_models:
            models.append({
                "id": f"ollama:{m['name']}",
                "name": m["name"],
                "provider": "ollama",
                "type": "local",
                "auth": "none",
                "size": m.get("size", 0),
                "quantization": m.get("details", {}).get("quantization_level", ""),
                "family": m.get("details", {}).get("family", ""),
            })

        keys = _read_keys(config.data_dir)

        cloud_catalog: dict[str, list[str]] = {
            "google": ["gemini-2.0-flash", "gemini-2.5-pro"],
            "anthropic": ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-3-5-20241022"],
            "openai": ["gpt-4o", "gpt-4o-mini", "o3-mini"],
        }

        # Google: OAuth subscription > API key
        google_auth = None
        if oauth is not None:
            try:
                token = oauth.get_token_sync("google")
                if token:
                    google_auth = "subscription"
            except Exception:
                pass
        if not google_auth and "google" in keys:
            google_auth = "api_key"
        if google_auth:
            for mid in cloud_catalog["google"]:
                models.append({
                    "id": mid, "name": mid,
                    "provider": "google", "type": "cloud",
                    "auth": google_auth,
                })

        # Claude: CLI subscription > API key
        claude_auth = None
        if shutil.which("claude"):
            claude_auth = "cli_subscription"
        if not claude_auth and "anthropic" in keys:
            claude_auth = "api_key"
        if claude_auth:
            for mid in cloud_catalog["anthropic"]:
                models.append({
                    "id": mid, "name": mid,
                    "provider": "anthropic", "type": "cloud",
                    "auth": claude_auth,
                })

        # OpenAI: API key only
        if "openai" in keys:
            for mid in cloud_catalog["openai"]:
                models.append({
                    "id": mid, "name": mid,
                    "provider": "openai", "type": "cloud",
                    "auth": "api_key",
                })

        return JSONResponse(
            {"models": models, "count": len(models)},
            headers={"Cache-Control": "public, max-age=60"},
        )

    # ── POST /models/pull ─────────────────────────────────────────

    @app.post("/models/pull")
    async def pull_model(request: Request) -> JSONResponse:
        """Pull/install an Ollama model. Streams progress."""
        body = await request.json()
        model_name = body.get("model", "").strip()
        if not model_name:
            raise HTTPException(400, "model name is required")

        # Only allow pulling known safe models
        allowed = {"llama3", "llama3.1", "mistral", "phi3", "gemma", "codellama",
                   "deepseek-r1", "qwen2", "llama3:latest", "mistral:latest"}
        base_name = model_name.split(":")[0].lower()
        if base_name not in {a.split(":")[0] for a in allowed}:
            raise HTTPException(400, f"Model '{model_name}' is not in the allowed list")

        import urllib.request
        try:
            req = urllib.request.Request(
                f"{config.ollama_url}/api/pull",
                data=json.dumps({"name": model_name, "stream": False}).encode(),
                headers={"Content-Type": "application/json"},
                method="POST",
            )
            resp = urllib.request.urlopen(req, timeout=600)  # 10 min timeout for large models
            result = json.loads(resp.read().decode())
            return JSONResponse({
                "ok": True,
                "model": model_name,
                "status": result.get("status", "success"),
            })
        except urllib.error.URLError as e:
            raise HTTPException(503, f"Ollama not reachable: {e}")
        except Exception as e:
            raise HTTPException(500, f"Failed to pull model: {e}")

    # ── GET /agents ────────────────────────────────────────────────

    @app.get("/agents")
    async def list_agents() -> JSONResponse:
        """List installed local agents + available marketplace agents."""
        agents: list[dict[str, Any]] = []

        # Scan local agent installs
        agent_dir = config.data_dir / "agents"
        if agent_dir.exists():
            for manifest_path in agent_dir.glob("*/manifest.json"):
                try:
                    manifest = json.loads(manifest_path.read_text("utf-8"))
                    agents.append({**manifest, "installed": True, "type": "local"})
                except Exception:
                    pass

        # Marketplace agents from cloud (best-effort)
        try:
            for a in cloud.list_marketplace_agents():
                agents.append({
                    "agent_id": a.agent_id, "name": a.name,
                    "description": a.description, "category": a.category,
                    "type": a.type, "version": a.version,
                    "rating": a.rating, "installs": a.installs,
                    "installed": False,
                })
        except CloudError:
            logger.debug("Could not fetch marketplace agents")

        return JSONResponse({"agents": agents, "count": len(agents)})

    # ── POST /agents/install ───────────────────────────────────────

    @app.post("/onboard/pull-model")
    async def pull_model(request: Request):
        body = await request.json()
        model_name = body.get("model")
        if not model_name:
            return JSONResponse({"error": "No model specified"}, status_code=400)
        import subprocess
        try:
            subprocess.run(["ollama", "pull", model_name], check=True, capture_output=True)
            return JSONResponse({"status": "pulled"})
        except Exception as e:
            return JSONResponse({"error": str(e)}, status_code=500)

    @app.post("/agents/install")
    async def install_agent(request: Request):
        """Install an agent from the marketplace catalog.

        Supports three install types:
        - mcp_stdio: MCP servers (most common) — saves config, npx handles runtime.
        - local_agent: Downloaded + sandboxed on user's machine.
        - remote_agent: Just saves the remote endpoint URL.
        """
        body = await request.json()
        agent_id = body.get("agent_id", "").strip()
        if not agent_id:
            raise HTTPException(400, "agent_id is required")
        # Prevent path traversal
        import re as _re_val
        if not _re_val.match(r'^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$', agent_id):
            raise HTTPException(400, "Invalid agent_id — alphanumeric, hyphens, dots, underscores only")

        install_type = body.get("install_type", "mcp_stdio")

        # For MCP servers: save config, no download needed
        if install_type == "mcp_stdio":
            mcp_command = body.get("mcp_command")
            if not mcp_command or not isinstance(mcp_command, list):
                raise HTTPException(400, "mcp_command is required for MCP agents")

            # Verify npx package
            package_status = "unknown"
            if mcp_command and mcp_command[0] == "npx":
                try:
                    import subprocess
                    package = mcp_command[mcp_command.index("-y") + 1] if "-y" in mcp_command else mcp_command[1]
                    res = subprocess.run(["npx", "-y", package, "--version"], capture_output=True, text=True, timeout=15)
                    package_status = "success" if res.returncode == 0 else f"failed: {res.stderr.strip()}"
                except Exception as e:
                    package_status = f"failed: {e}"

            # Save to ~/.brynq/agents/{agent_id}/manifest.json
            agent_dir = config.data_dir / "agents" / agent_id
            agent_dir.mkdir(parents=True, exist_ok=True)
            manifest = {
                "agent_id": agent_id,
                "name": body.get("name", agent_id),
                "description": body.get("description", ""),
                "install_type": "mcp_stdio",
                "mcp_command": mcp_command,
                "category": body.get("category", ""),
                "installed_at": time.time(),
                "status": "installed",
                "npx_status": package_status,
                "price_per_call": body.get("price_per_call", 0),
            }
            (agent_dir / "manifest.json").write_text(
                json.dumps(manifest, indent=2), encoding="utf-8"
            )
            return {"status": "installed", "agent_id": agent_id, "install_type": "mcp_stdio", "npx_status": package_status}

        # For local agents: use sandbox to download and install
        elif install_type == "local_agent":
            package_url = body.get("package_url")
            if not package_url:
                raise HTTPException(400, "package_url is required for local agents")
            try:
                from runtime.sandbox.agent_sandbox import AgentSandbox
                sandbox = AgentSandbox(config.data_dir / "agents")
                installed = sandbox.install_agent(
                    agent_id=agent_id,
                    package_url=package_url,
                    name=body.get("name", ""),
                    version=body.get("version", "0.0.0"),
                    entrypoint=body.get("entrypoint", "python -m agent"),
                )
                return {"status": "installed", "agent_id": agent_id, "install_type": "local_agent"}
            except Exception as e:
                raise HTTPException(500, f"Install failed: {e}")

        # For remote agents: just save the endpoint
        elif install_type == "remote_agent":
            endpoint = body.get("endpoint")
            if not endpoint:
                raise HTTPException(400, "endpoint is required for remote agents")
            agent_dir = config.data_dir / "agents" / agent_id
            agent_dir.mkdir(parents=True, exist_ok=True)
            manifest = {
                "agent_id": agent_id,
                "name": body.get("name", agent_id),
                "description": body.get("description", ""),
                "install_type": "remote_agent",
                "endpoint": endpoint,
                "category": body.get("category", ""),
                "installed_at": time.time(),
                "status": "installed",
                "price_per_call": body.get("price_per_call", 0),
            }
            (agent_dir / "manifest.json").write_text(
                json.dumps(manifest, indent=2), encoding="utf-8"
            )
            return {"status": "installed", "agent_id": agent_id, "install_type": "remote_agent"}

        raise HTTPException(400, f"Unknown install_type: {install_type}")

    @app.post("/agents/{agent_id}/test")
    async def test_agent(agent_id: str):
        agent_dir = config.data_dir / "agents" / agent_id
        manifest_path = agent_dir / "manifest.json"
        if not manifest_path.exists():
            raise HTTPException(404, "Agent not installed")

        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))

        if manifest.get("install_type") != "mcp_stdio":
            raise HTTPException(400, "Only mcp_stdio agents can be tested this way")

        mcp_command = manifest.get("mcp_command", [])
        if not mcp_command:
            raise HTTPException(400, "No mcp_command in manifest")

        try:
            from runtime.mcp_adapter import MCPServerConnection, MCPAgentAdapter
            conn = MCPServerConnection(command=mcp_command, sandbox=True)
            conn.connect()
            adapter = MCPAgentAdapter(conn)
            tools = adapter.get_tools()
            conn.close()

            manifest["tools"] = tools
            manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")

            return {"status": "success", "agent_id": agent_id, "tools": tools}
        except Exception as e:
            raise HTTPException(500, f"Test failed: {e}")

    @app.post("/agents/{agent_id}/call")
    async def call_agent(agent_id: str, request: Request):
        body = await request.json()
        prompt = body.get("prompt", "")

        agent_dir = config.data_dir / "agents" / agent_id
        manifest_path = agent_dir / "manifest.json"
        if not manifest_path.exists():
            raise HTTPException(404, "Agent not installed")

        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))

        if manifest.get("install_type") != "mcp_stdio":
            raise HTTPException(400, "Only mcp_stdio agents can be called directly via adapter here")

        mcp_command = manifest.get("mcp_command", [])
        if not mcp_command:
            raise HTTPException(400, "No mcp_command in manifest")

        try:
            from runtime.mcp_adapter import MCPServerConnection, MCPAgentAdapter
            conn = MCPServerConnection(command=mcp_command, sandbox=True)
            conn.connect()
            adapter = MCPAgentAdapter(conn)
            response = adapter.execute_prompt(prompt) if hasattr(adapter, 'execute_prompt') else f"[MCP Executed]: {prompt}"
            conn.close()

            return {"status": "success", "agent_id": agent_id, "response": response}
        except Exception as e:
            raise HTTPException(500, f"Call failed: {e}")

    @app.get("/agents/{agent_id}/price")
    async def agent_price(agent_id: str):
        try:
            cat_entry = cloud.get_marketplace_agent(agent_id)
            if cat_entry:
                return {"price_per_call": getattr(cat_entry, "price_per_call", 0)}
        except Exception:
            pass
        # Fallback to local manifest if available
        agent_dir = config.data_dir / "agents" / agent_id
        manifest_path = agent_dir / "manifest.json"
        if manifest_path.exists():
            manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
            return {"price_per_call": manifest.get("price_per_call", 0)}
        return {"price_per_call": 0}

    # ── DELETE /agents/{agent_id} ──────────────────────────────────

    @app.delete("/agents/{agent_id}")
    async def uninstall_agent(agent_id: str):
        """Uninstall an agent by removing its directory."""
        agent_dir = config.data_dir / "agents" / agent_id
        if not agent_dir.exists():
            raise HTTPException(404, "Agent not installed")
        import shutil
        shutil.rmtree(agent_dir, ignore_errors=True)
        return {"status": "uninstalled", "agent_id": agent_id}

    # ── GET /agents/installed ──────────────────────────────────────

    @app.get("/agents/installed")
    async def list_installed_agents():
        """List all installed agents by scanning manifests."""
        agents_dir = config.data_dir / "agents"
        if not agents_dir.exists():
            return {"agents": [], "count": 0}
        installed = []
        for manifest_path in agents_dir.glob("*/manifest.json"):
            try:
                manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
                installed.append(manifest)
            except Exception:
                continue
        return {"agents": installed, "count": len(installed)}

    # ── POST /agents/{agent_id}/start ──────────────────────────────

    @app.post("/agents/{agent_id}/start")
    async def start_agent(agent_id: str):
        """Start an MCP agent (on-demand, not persistent)."""
        agent_dir = config.data_dir / "agents" / agent_id
        manifest_path = agent_dir / "manifest.json"
        if not manifest_path.exists():
            raise HTTPException(404, "Agent not installed")

        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))

        if manifest.get("install_type") == "mcp_stdio":
            # MCP agents are started on-demand, not as persistent processes
            # Just verify the command exists
            mcp_command = manifest.get("mcp_command", [])
            if not mcp_command:
                raise HTTPException(400, "No mcp_command in manifest")
            return {
                "status": "ready",
                "agent_id": agent_id,
                "mcp_command": mcp_command,
                "note": "MCP agents start on-demand when called",
            }

        raise HTTPException(400, "Only mcp_stdio agents supported for now")

    # ── GET /health ────────────────────────────────────────────────

    @app.get("/health")
    async def health() -> JSONResponse:
        """Lightweight health check for the frontend chat store.

        Returns a flat dict with top-level boolean flags that the
        frontend expects: { ollama: true/false, cloud: true/false }.
        """
        ollama_ok = _ollama_is_running(config.ollama_url)
        cloud_ok = cloud.health_check()
        return JSONResponse({
            "status": "ok",
            "version": get_version(),
            "uptime_seconds": round(time.time() - _START_TIME, 2),
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "ollama": ollama_ok,
            "cloud": cloud_ok,
        })

    # ── GET /version ──────────────────────────────────────────────

    @app.get("/version")
    async def version() -> JSONResponse:
        """Return runtime version info. No auth required."""
        return JSONResponse({
            "version": "0.1.0",
            "runtime": "brynq",
            "api_version": "v1",
        })

    # ── GET /stats ─────────────────────────────────────────────────

    @app.get("/stats")
    async def stats() -> JSONResponse:
        """Runtime statistics: request count, uptime, active models."""
        local_models = _ollama_list_models(config.ollama_url)
        keys = _read_keys(config.data_dir)
        # Count cloud providers with stored keys
        cloud_provider_count = len(keys)
        return JSONResponse({
            "total_requests": _request_count,
            "uptime_seconds": round(time.time() - _START_TIME, 2),
            "active_model_count": len(local_models) + cloud_provider_count,
            "timestamp": datetime.now(timezone.utc).isoformat(),
        })

    # ── GET /status ────────────────────────────────────────────────

    @app.get("/status")
    async def status() -> JSONResponse:
        """Runtime health: Ollama, cloud connection, stored keys."""
        ollama_ok = _ollama_is_running(config.ollama_url)
        cloud_ok = cloud.health_check()
        keys = _read_keys(config.data_dir)
        local_models = _ollama_list_models(config.ollama_url) if ollama_ok else []

        return JSONResponse({
            "status": "ok",
            "ollama": {
                "connected": ollama_ok,
                "url": config.ollama_url,
                "model_count": len(local_models),
            },
            "cloud": {
                "connected": cloud_ok,
                "url": config.cloud_url,
                "authenticated": cloud.is_authenticated,
            },
            "keys": {
                "count": len(keys),
                "providers": list(keys.keys()),
            },
            "version": get_version(),
        })

    # ── POST /keys ─────────────────────────────────────────────────

    @app.post("/keys")
    async def store_key(request: Request) -> JSONResponse:
        """Store an API key locally. Never sent to the cloud.

        Body: {"provider": "anthropic", "api_key": "sk-..."}
        """
        body = await request.json()
        provider = body.get("provider", "").strip().lower()
        api_key = (body.get("api_key") or body.get("key") or "").strip()

        if not provider:
            raise HTTPException(status_code=400, detail="provider is required")
        if not api_key:
            raise HTTPException(status_code=400, detail="api_key is required")
        if provider not in VALID_PROVIDERS:
            raise HTTPException(
                status_code=400,
                detail=f"Unknown provider: {provider}. Valid: {', '.join(sorted(VALID_PROVIDERS))}",
            )

        keys = _read_keys(config.data_dir)
        keys[provider] = api_key
        _write_keys(config.data_dir, keys)

        return JSONResponse({
            "stored": True,
            "provider": provider,
            "masked_key": _mask_key(api_key),
        })

    # ── GET /keys ──────────────────────────────────────────────────

    @app.get("/keys")
    async def list_keys() -> JSONResponse:
        """List stored API keys with masked values. Never exposes full keys."""
        keys = _read_keys(config.data_dir)
        return JSONResponse({
            "keys": [
                {"provider": p, "masked_key": _mask_key(k)}
                for p, k in keys.items()
            ],
            "count": len(keys),
        })

    # ── DELETE /keys/{provider} ────────────────────────────────────

    @app.delete("/keys/{provider}")
    async def delete_key(provider: str) -> JSONResponse:
        """Remove a stored API key."""
        provider = provider.strip().lower()
        keys = _read_keys(config.data_dir)
        if provider not in keys:
            return JSONResponse({"status": "not_found", "provider": provider})

        del keys[provider]
        _write_keys(config.data_dir, keys)
        return JSONResponse({"deleted": True, "provider": provider})

    # ── POST /keys/{provider}/test ────────────────────────────────

    @app.post("/keys/{provider}/test")
    async def test_key(provider: str) -> JSONResponse:
        """Test that a stored API key works by making a minimal API call.

        Returns {"ok": true} on success, raises on failure.
        """
        provider = provider.strip().lower()
        keys = _read_keys(config.data_dir)
        api_key = keys.get(provider)
        if not api_key:
            raise HTTPException(status_code=404, detail=f"No key stored for {provider}")

        try:
            if provider == "anthropic":
                # Minimal Anthropic call — list models
                req = urllib.request.Request(
                    "https://api.anthropic.com/v1/messages",
                    data=json.dumps({
                        "model": "claude-haiku-3-5-20241022",
                        "max_tokens": 1,
                        "messages": [{"role": "user", "content": "hi"}],
                    }).encode("utf-8"),
                    headers={
                        "Content-Type": "application/json",
                        "x-api-key": api_key,
                        "anthropic-version": "2023-06-01",
                    },
                    method="POST",
                )
                with urllib.request.urlopen(req, timeout=15):
                    pass

            elif provider == "openai":
                req = urllib.request.Request(
                    "https://api.openai.com/v1/models",
                    headers={"Authorization": f"Bearer {api_key}"},
                    method="GET",
                )
                with urllib.request.urlopen(req, timeout=15):
                    pass

            elif provider == "google":
                url = (
                    "https://generativelanguage.googleapis.com/v1beta"
                    f"/models?key={api_key}"
                )
                req = urllib.request.Request(url, method="GET")
                with urllib.request.urlopen(req, timeout=15):
                    pass

            else:
                raise HTTPException(
                    status_code=400,
                    detail=f"Key testing not supported for {provider}",
                )

        except urllib.error.HTTPError as e:
            raise HTTPException(
                status_code=400,
                detail=f"Key test failed for {provider}: HTTP {e.code}",
            )
        except urllib.error.URLError as e:
            raise HTTPException(
                status_code=502,
                detail=f"Could not reach {provider}: {e.reason}",
            )

        return JSONResponse({"ok": True, "provider": provider})

    # ── POST /email/configure ─────────────────────────────────────

    @app.post("/email/configure")
    async def email_configure(request: Request) -> JSONResponse:
        """Save SMTP email configuration.

        Body: {"host": "smtp.gmail.com", "port": 587, "user": "u@gmail.com",
               "password": "app-password", "from_addr": "u@gmail.com", "use_tls": true}
        """
        from runtime.integrations.email_sender import EmailConfig

        body = await request.json()
        host = (body.get("host") or "").strip()
        port = body.get("port")
        user = (body.get("user") or "").strip()
        password = body.get("password") or ""
        from_addr = (body.get("from_addr") or "").strip()
        use_tls = body.get("use_tls", True)

        if not host:
            raise HTTPException(status_code=400, detail="host is required")
        if port is None:
            raise HTTPException(status_code=400, detail="port is required")
        if not user:
            raise HTTPException(status_code=400, detail="user is required")
        if not password:
            raise HTTPException(status_code=400, detail="password is required")
        if not from_addr:
            raise HTTPException(status_code=400, detail="from_addr is required")

        try:
            port = int(port)
        except (TypeError, ValueError):
            raise HTTPException(status_code=400, detail="port must be an integer")

        try:
            cfg = EmailConfig()
            cfg.configure(host, port, user, password, from_addr, use_tls=use_tls)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc))

        return JSONResponse({"configured": True})

    # ── POST /auth/login/{provider} ───────────────────────────────

    @app.post("/auth/login/{provider}")
    async def auth_login(provider: str) -> JSONResponse:
        """Start an OAuth login flow for a provider.

        Opens the user's browser to the provider's consent page and
        returns the authorization URL.

        Path params:
            provider: "anthropic", "google", or "openai"

        Returns:
            {"auth_url": "https://...", "provider": "anthropic"}
        """
        provider = provider.strip().lower()
        try:
            auth_url = await oauth.start_login(provider, open_browser=False)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc))

        return JSONResponse({
            "auth_url": auth_url,
            "provider": provider,
        })

    # ── GET /auth/callback/{provider} ─────────────────────────────

    @app.get("/auth/callback/{provider}")
    async def auth_callback(provider: str, request: Request) -> JSONResponse:
        """Handle the OAuth redirect from a provider.

        The provider redirects here with ?code=...&state=... after
        the user consents. Exchanges the code for tokens and stores
        them locally.

        Query params:
            code:  Authorization code from the provider.
            state: CSRF state parameter.
        """
        provider = provider.strip().lower()
        code = request.query_params.get("code")
        state = request.query_params.get("state")

        if not code:
            raise HTTPException(status_code=400, detail="Missing 'code' parameter")
        if not state:
            raise HTTPException(status_code=400, detail="Missing 'state' parameter")

        # Check for OAuth error response from provider
        error = request.query_params.get("error")
        if error:
            error_desc = request.query_params.get("error_description", "Unknown error")
            raise HTTPException(
                status_code=400,
                detail=f"OAuth error from {provider}: {error} - {error_desc}",
            )

        try:
            token_set = await oauth.handle_callback(provider, code, state)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc))
        except OAuthTokenError as exc:
            raise HTTPException(status_code=502, detail=str(exc))

        return JSONResponse({
            "connected": True,
            "provider": provider,
            "scopes": token_set.scopes,
            "user_email": token_set.user_email,
        })

    # ── GET /auth/connections ─────────────────────────────────────

    @app.get("/auth/connections")
    async def auth_connections() -> JSONResponse:
        """List all supported providers with their connection status.

        Returns a list of connections, each with provider name,
        display name, and status ("connected", "expired", or "not_connected").
        """
        connections = await oauth.list_connections()
        return JSONResponse({
            "connections": [
                {
                    "provider": c.provider,
                    "provider_name": c.provider_name,
                    "status": c.status,
                    "user_email": c.user_email,
                    "scopes": c.scopes,
                }
                for c in connections
            ],
        })

    # ── POST /auth/disconnect/{provider} (alias for logout) ─────

    @app.post("/auth/disconnect/{provider}")
    async def auth_disconnect(provider: str) -> JSONResponse:
        """Alias for /auth/logout — the frontend uses 'disconnect' naming."""
        return await auth_logout(provider)

    # ── POST /auth/logout/{provider} ──────────────────────────────

    @app.post("/auth/logout/{provider}")
    async def auth_logout(provider: str) -> JSONResponse:
        """Disconnect an OAuth provider (revoke + delete local tokens).

        Path params:
            provider: "anthropic", "google", or "openai"
        """
        provider = provider.strip().lower()
        try:
            await oauth.logout(provider)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc))

        return JSONResponse({
            "disconnected": True,
            "provider": provider,
        })

    # ── POST /projects ─────────────────────────────────────────────

    @app.post("/projects")
    async def create_project(request: Request) -> JSONResponse:
        """Create a new project.

        Body: {"name": "...", "description": "..."}
        Returns the created project with a generated ID.
        """
        body = await request.json()
        name = (body.get("name") or "").strip()
        if not name:
            raise HTTPException(status_code=400, detail="name is required")

        description = (body.get("description") or "").strip()
        now = _now_iso()
        project: dict[str, Any] = {
            "id": f"proj_{uuid.uuid4().hex[:12]}",
            "name": name,
            "description": description,
            "created_at": now,
            "updated_at": now,
            "agents": [],
            "chat_ids": [],
        }

        projects = _read_projects(config.data_dir)
        projects.append(project)
        _write_projects(config.data_dir, projects)

        return JSONResponse(project, status_code=201)

    # ── GET /projects ──────────────────────────────────────────────

    @app.get("/projects")
    async def list_projects() -> JSONResponse:
        """List all projects."""
        projects = _read_projects(config.data_dir)
        return JSONResponse({"projects": projects, "count": len(projects)})

    # ── GET /projects/{project_id} ─────────────────────────────────

    @app.get("/projects/{project_id}")
    async def get_project(project_id: str) -> JSONResponse:
        """Get a single project by ID."""
        projects = _read_projects(config.data_dir)
        project = _find_project(projects, project_id)
        if project is None:
            raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
        return JSONResponse(project)

    # ── PUT /projects/{project_id} ─────────────────────────────────

    @app.put("/projects/{project_id}")
    async def update_project(project_id: str, request: Request) -> JSONResponse:
        """Update a project's name, description, or agents list.

        Body: any of {"name": "...", "description": "...", "agents": [...]}
        """
        body = await request.json()
        projects = _read_projects(config.data_dir)
        project = _find_project(projects, project_id)
        if project is None:
            raise HTTPException(status_code=404, detail=f"Project {project_id} not found")

        if "name" in body:
            name = (body["name"] or "").strip()
            if not name:
                raise HTTPException(status_code=400, detail="name cannot be empty")
            project["name"] = name
        if "description" in body:
            project["description"] = (body["description"] or "").strip()
        if "agents" in body:
            if not isinstance(body["agents"], list):
                raise HTTPException(status_code=400, detail="agents must be a list")
            project["agents"] = body["agents"]

        project["updated_at"] = _now_iso()
        _write_projects(config.data_dir, projects)

        return JSONResponse(project)

    # ── DELETE /projects/{project_id} ──────────────────────────────

    @app.delete("/projects/{project_id}")
    async def delete_project(project_id: str) -> JSONResponse:
        """Delete a project. Chat history is NOT deleted."""
        projects = _read_projects(config.data_dir)
        original_len = len(projects)
        projects = [p for p in projects if p["id"] != project_id]
        if len(projects) == original_len:
            raise HTTPException(status_code=404, detail=f"Project {project_id} not found")

        _write_projects(config.data_dir, projects)
        return JSONResponse({"deleted": True, "id": project_id})

    # ── POST /projects/{project_id}/agents ─────────────────────────

    @app.post("/projects/{project_id}/agents")
    async def add_agent_to_project(project_id: str, request: Request) -> JSONResponse:
        """Add an agent (model) to a project.

        Body: {"model": "llama3"}
        """
        body = await request.json()
        model = (body.get("model") or "").strip()
        if not model:
            raise HTTPException(status_code=400, detail="model is required")

        projects = _read_projects(config.data_dir)
        project = _find_project(projects, project_id)
        if project is None:
            raise HTTPException(status_code=404, detail=f"Project {project_id} not found")

        if model in project["agents"]:
            raise HTTPException(status_code=409, detail=f"Agent {model} already in project")

        project["agents"].append(model)
        project["updated_at"] = _now_iso()
        _write_projects(config.data_dir, projects)

        return JSONResponse({"added": True, "model": model, "agents": project["agents"]})

    # ── DELETE /projects/{project_id}/agents/{model} ───────────────

    @app.delete("/projects/{project_id}/agents/{model}")
    async def remove_agent_from_project(project_id: str, model: str) -> JSONResponse:
        """Remove an agent (model) from a project."""
        projects = _read_projects(config.data_dir)
        project = _find_project(projects, project_id)
        if project is None:
            raise HTTPException(status_code=404, detail=f"Project {project_id} not found")

        if model not in project["agents"]:
            raise HTTPException(status_code=404, detail=f"Agent {model} not in project")

        project["agents"].remove(model)
        project["updated_at"] = _now_iso()
        _write_projects(config.data_dir, projects)

        return JSONResponse({"removed": True, "model": model, "agents": project["agents"]})

    # ── RAG (Retrieval-Augmented Generation) ──────────────────────

    @app.get("/rag/list")
    async def rag_list_files() -> JSONResponse:
        """List all indexed RAG files with chunk/token counts."""
        from runtime.rag import list_files as rag_list_files_fn

        files = rag_list_files_fn(config.data_dir)
        return JSONResponse({"files": files, "total": len(files)})

    @app.post("/rag/index")
    async def rag_index_file(request: Request) -> JSONResponse:
        """Index a text file for RAG retrieval.

        Body: {"filepath": "/absolute/path/to/file.txt"}
        """
        body = await request.json()
        filepath = (body.get("filepath") or "").strip()
        if not filepath:
            raise HTTPException(status_code=400, detail="filepath is required")

        import os as _os
        _max_size = 50 * 1024 * 1024  # 50MB
        if _os.path.isfile(filepath) and _os.path.getsize(filepath) > _max_size:
            raise HTTPException(status_code=400, detail="File too large (max 50MB)")

        from runtime.rag import add_file as rag_add_file

        try:
            result = rag_add_file(config.data_dir, filepath)
            return JSONResponse(result, status_code=201)
        except FileNotFoundError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        except ValueError as exc:
            raise HTTPException(status_code=422, detail=str(exc)) from exc

    @app.delete("/rag/files")
    async def rag_remove_file(request: Request) -> JSONResponse:
        """Remove an indexed file and its chunks.

        Body: {"filepath": "/absolute/path/to/file.txt"}
        """
        body = await request.json()
        filepath = (body.get("filepath") or "").strip()
        if not filepath:
            raise HTTPException(status_code=400, detail="filepath is required")

        from runtime.rag import remove_file as rag_remove_file

        removed = rag_remove_file(config.data_dir, filepath)
        if not removed:
            raise HTTPException(status_code=404, detail="File not found in index")
        return JSONResponse({"removed": True, "filepath": filepath})

    @app.get("/rag/search")
    async def rag_search(q: str = "", top_k: int = 5) -> JSONResponse:
        """Search indexed RAG chunks by query string.

        Query params: ?q=search+terms&top_k=5
        Returns ranked chunks with relevance scores.
        """
        if not q.strip():
            raise HTTPException(status_code=400, detail="q parameter is required")

        from runtime.rag import query as rag_query

        results = rag_query(config.data_dir, q.strip(), top_k=top_k)
        return JSONResponse({"results": results, "query": q, "total": len(results)})

    # ── Webhooks ────────────────────────────────────────────────────────

    @app.get("/webhooks")
    async def webhooks_list() -> JSONResponse:
        """Return all registered webhooks."""
        from runtime.integrations.webhooks import WebhookRegistry

        registry = WebhookRegistry()
        return JSONResponse({"webhooks": registry.list_all()})

    @app.post("/webhooks/register")
    async def webhooks_register(request: Request) -> JSONResponse:
        """Register a webhook that triggers a chain via external HTTP call.

        Expects JSON body: {name, chain_template, auth_token}.
        Returns: {webhook_id, trigger_url}.
        """
        body = await request.json()
        name = body.get("name")
        chain_template = body.get("chain_template")
        auth_token = body.get("auth_token")
        if not name or not chain_template or not auth_token:
            raise HTTPException(
                status_code=400,
                detail="name, chain_template, and auth_token are required",
            )

        from runtime.integrations.webhooks import WebhookRegistry

        registry = WebhookRegistry()
        webhook_id = registry.register(name, chain_template, auth_token)
        return JSONResponse({
            "webhook_id": webhook_id,
            "trigger_url": f"/webhooks/{webhook_id}/trigger",
        })

    @app.post("/webhooks/{webhook_id}/trigger")
    async def webhooks_trigger(webhook_id: str, request: Request) -> JSONResponse:
        """Trigger a webhook by ID with Bearer token authentication.

        Validates the Authorization header against the stored token hash,
        then forwards the request body text to the /chat endpoint internally.
        Returns 404 for unknown webhook, 403 for bad auth.
        """
        from runtime.integrations.webhooks import WebhookRegistry

        registry = WebhookRegistry()

        # Check webhook exists
        webhook = registry.get(webhook_id)
        if webhook is None:
            raise HTTPException(status_code=404, detail="Webhook not found")

        # Extract and validate Bearer token
        auth_header = request.headers.get("authorization", "")
        if not auth_header.startswith("Bearer "):
            raise HTTPException(status_code=403, detail="Invalid or missing auth token")
        token = auth_header[len("Bearer "):]

        if not registry.validate_auth(webhook_id, token):
            raise HTTPException(status_code=403, detail="Invalid or missing auth token")

        # Read request body and forward to /chat internally
        body_bytes = await request.body()
        message = body_bytes.decode("utf-8").strip()
        if not message:
            raise HTTPException(status_code=400, detail="Request body must not be empty")

        # Forward to /chat via internal HTTP call to localhost
        import urllib.request as _urllib_request

        chat_payload = json.dumps({"message": message}).encode()
        chat_req = _urllib_request.Request(
            f"http://127.0.0.1:{config.local_port}/chat",
            data=chat_payload,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        try:
            with _urllib_request.urlopen(chat_req, timeout=120) as resp:
                resp_body = json.loads(resp.read().decode())
                return JSONResponse(resp_body)
        except urllib.error.HTTPError as exc:
            resp_body = json.loads(exc.read().decode())
            return JSONResponse(resp_body, status_code=exc.code)

    @app.delete("/webhooks/{webhook_id}")
    async def webhooks_delete(webhook_id: str) -> JSONResponse:
        """Delete a webhook by ID.

        Returns 200 on success, 404 if not found.
        """
        from runtime.integrations.webhooks import WebhookRegistry

        registry = WebhookRegistry()
        deleted = registry.delete(webhook_id)
        if not deleted:
            raise HTTPException(status_code=404, detail="Webhook not found")
        return JSONResponse({"status": "deleted", "webhook_id": webhook_id})

    # ── Schedules ───────────────────────────────────────────────────────

    @app.post("/schedules")
    async def schedules_create(request: Request) -> JSONResponse:
        """Create a scheduled chain execution.

        Expects JSON body: {name, cron_expr, chain_template}.
        Returns: {schedule_id, next_run}.
        Returns 400 if cron_expr is invalid.
        """
        body = await request.json()
        name = body.get("name")
        cron_expr = body.get("cron_expr")
        chain_template = body.get("chain_template")
        if not name or not cron_expr or not chain_template:
            raise HTTPException(
                status_code=400,
                detail="name, cron_expr, and chain_template are required",
            )

        from runtime.integrations.scheduler import ScheduleStore, next_run, parse_cron

        try:
            store = ScheduleStore()
            schedule_id = store.create(name, cron_expr, chain_template)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc))

        cron_dict = parse_cron(cron_expr)
        computed_next = next_run(cron_dict, datetime.now()).isoformat()

        return JSONResponse({
            "schedule_id": schedule_id,
            "next_run": computed_next,
        })

    # ── Templates ──────────────────────────────────────────────────────

    @app.get("/templates")
    async def list_templates(tag: str | None = None) -> JSONResponse:
        """List all templates, optionally filtered by tag."""
        from runtime.integrations.templates import TemplateStore

        store = TemplateStore()
        templates = store.list_all(tag=tag)
        return JSONResponse({"templates": templates})

    @app.post("/templates")
    async def save_template(request: Request) -> JSONResponse:
        """Save a new chain template."""
        from runtime.integrations.templates import TemplateStore

        body = await request.json()
        name = body.get("name")
        chain_config = body.get("chain_config")
        description = body.get("description", "")

        if not name or not chain_config:
            return JSONResponse(
                {"error": "name and chain_config are required"},
                status_code=400,
            )

        store = TemplateStore()
        template_id = store.save(
            name=name,
            chain_config=chain_config,
            description=description,
        )
        return JSONResponse({"template_id": template_id})

    # ── POST /batch ───────────────────────────────────────────────

    @app.post("/batch")
    async def start_batch(request: Request) -> JSONResponse:
        """Start batch processing of a prompt file in the background.

        Accepts a filepath to a batch file, loads prompts, and runs them
        concurrently via :func:`run_batch`. Returns a job_id immediately.
        """
        body = await request.json()
        filepath = body.get("filepath")
        if not filepath or not isinstance(filepath, str):
            raise HTTPException(status_code=400, detail="filepath is required")

        model = body.get("model")
        strategy = body.get("strategy")
        output_format = body.get("output_format", "json")

        from runtime.integrations.batch import load_batch_file, run_batch

        try:
            prompts = load_batch_file(filepath)
        except FileNotFoundError:
            raise HTTPException(status_code=404, detail=f"Batch file not found: {filepath}")
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e))

        if not prompts:
            raise HTTPException(status_code=400, detail="Batch file contains no prompts")

        endpoint_url = f"http://127.0.0.1:{config.local_port}/chain"
        job_id = str(uuid.uuid4())

        task = asyncio.create_task(
            run_batch(prompts, endpoint_url, model=model, strategy=strategy),
            name=f"batch-{job_id}",
        )
        _batch_jobs[job_id] = task

        return JSONResponse({"job_id": job_id, "status": "running"})

    # ── Batch job status ──────────────────────────────────────────────

    @app.get("/batch/{job_id}")
    async def get_batch_status(job_id: str) -> JSONResponse:
        """Return progress for a background batch job."""
        progress = _batch_jobs.get(job_id)
        if progress is None:
            raise HTTPException(status_code=404, detail=f"Unknown batch job: {job_id}")
        summary = progress.summary()
        return JSONResponse({
            "job_id": job_id,
            "status": "completed" if (summary["completed"] + summary["failed"]) >= summary["total"] else "running",
            "progress_percent": summary["percent"],
            "completed": summary["completed"],
            "failed": summary["failed"],
            "eta_seconds": summary["eta_seconds"],
        })

    # ── POST /v1/debate ──────────────────────────────────────────────

    @app.post("/v1/debate")
    async def v1_debate(request: Request) -> JSONResponse:
        """Multi-model debate: models argue in rounds, then produce a conclusion.

        Accepts:
            prompt: str        — the topic or question to debate
            models: list[str]  — model IDs to participate (at least 2)
            rounds: int        — number of debate rounds (1-10)

        Returns:
            conclusion: str                              — final synthesised answer
            rounds:     list[{round, model, argument}]   — per-round arguments
        """
        body = await request.json()

        prompt = body.get("prompt", "")
        if not isinstance(prompt, str) or not prompt.strip():
            raise HTTPException(status_code=400, detail="prompt must be a non-empty string")

        models = body.get("models", [])
        if not isinstance(models, list) or len(models) < 2:
            raise HTTPException(status_code=400, detail="models must be a list of at least 2 model IDs")
        if not all(isinstance(m, str) and m.strip() for m in models):
            raise HTTPException(status_code=400, detail="each model must be a non-empty string")

        rounds = body.get("rounds", 3)
        if not isinstance(rounds, int) or isinstance(rounds, bool):
            raise HTTPException(status_code=400, detail="rounds must be an integer")
        if rounds < 1 or rounds > 10:
            raise HTTPException(status_code=400, detail="rounds must be between 1 and 10")

        keys = _read_keys(config.data_dir)
        debate_rounds: list[dict[str, Any]] = []
        wall_start = time.time()

        for round_num in range(1, rounds + 1):
            for model_id in models:
                # Build context from prior arguments
                prior_args = ""
                if debate_rounds:
                    prior_args = "\n\n".join(
                        f"[Round {r['round']}] {r['model']}:\n{r['argument']}"
                        for r in debate_rounds
                    )

                if round_num == 1 and not debate_rounds:
                    full_prompt = (
                        f"You are participating in a structured debate. "
                        f"The topic is: {prompt.strip()}\n\n"
                        f"Present your argument."
                    )
                else:
                    full_prompt = (
                        f"You are participating in a structured debate. "
                        f"The topic is: {prompt.strip()}\n\n"
                        f"Previous arguments:\n{prior_args}\n\n"
                        f"This is round {round_num}. Respond to the other arguments "
                        f"and strengthen your position."
                    )

                try:
                    argument = _debate_call_model(
                        model_id, full_prompt, config, keys, oauth_manager=oauth,
                    )
                except HTTPException:
                    raise
                except Exception as e:
                    argument = f"[Error: {e}]"

                debate_rounds.append({
                    "round": round_num,
                    "model": model_id,
                    "argument": argument,
                })

        # Synthesise conclusion using the first model
        all_args = "\n\n".join(
            f"[Round {r['round']}] {r['model']}:\n{r['argument']}"
            for r in debate_rounds
        )
        conclusion_prompt = (
            f"You are a neutral judge. The topic was: {prompt.strip()}\n\n"
            f"Here is the full debate:\n{all_args}\n\n"
            f"Synthesise the strongest points from all sides into a balanced conclusion."
        )
        try:
            conclusion = _debate_call_model(
                models[0], conclusion_prompt, config, keys, oauth_manager=oauth,
            )
        except Exception:
            # Fallback: use last argument as conclusion
            conclusion = debate_rounds[-1]["argument"] if debate_rounds else ""

        total_ms = int((time.time() - wall_start) * 1000)

        return JSONResponse({
            "conclusion": conclusion,
            "rounds": debate_rounds,
            "total_latency_ms": total_ms,
        })

    @app.post("/v1/refine")
    async def v1_refine(request: Request) -> JSONResponse:
        """Iterative refinement: each model improves the previous model's output.

        Accepts:
            prompt: str        — the initial prompt / content to refine
            models: list[str]  — ordered model IDs (each refines previous output)

        Returns:
            final_output: str                          — last model's refined output
            iterations:   list[{model, output}]        — per-model outputs in order
        """
        body = await request.json()

        prompt = body.get("prompt", "")
        if not isinstance(prompt, str) or not prompt.strip():
            raise HTTPException(status_code=400, detail="prompt must be a non-empty string")

        models = body.get("models", [])
        if not isinstance(models, list) or len(models) < 1:
            raise HTTPException(status_code=400, detail="models must be a list of at least 1 model ID")
        if not all(isinstance(m, str) and m.strip() for m in models):
            raise HTTPException(status_code=400, detail="each model must be a non-empty string")

        keys = _read_keys(config.data_dir)
        iterations: list[dict[str, Any]] = []
        wall_start = time.time()

        current_output = ""
        for i, model_id in enumerate(models):
            if i == 0:
                refine_prompt = (
                    f"Please respond to the following request:\n\n"
                    f"{prompt.strip()}"
                )
            else:
                refine_prompt = (
                    f"The original request was:\n{prompt.strip()}\n\n"
                    f"A previous model produced the following response:\n"
                    f"{current_output}\n\n"
                    f"Please improve, refine, and enhance this response. "
                    f"Fix any errors, add missing details, and improve clarity."
                )

            try:
                current_output = _debate_call_model(
                    model_id, refine_prompt, config, keys, oauth_manager=oauth,
                )
            except HTTPException:
                raise
            except Exception as e:
                current_output = f"[Error: {e}]"

            iterations.append({
                "model": model_id,
                "output": current_output,
            })

        total_ms = int((time.time() - wall_start) * 1000)

        return JSONResponse({
            "final_output": current_output,
            "iterations": iterations,
            "total_latency_ms": total_ms,
        })

    @app.post("/v1/single")
    async def v1_single(request: Request) -> JSONResponse:
        """Send a prompt to one model and return its response.

        Accepts:
            prompt: str  — the user prompt
            model:  str  — model ID (e.g. "ollama:llama3", "claude-sonnet-4-20250514")

        Returns:
            output:     str  — the model's text response
            model:      str  — model ID used
            tokens:     int  — token count
            latency_ms: int  — wall-clock latency in milliseconds
        """
        body = await request.json()

        prompt = body.get("prompt", "")
        if not isinstance(prompt, str) or not prompt.strip():
            raise HTTPException(status_code=400, detail="prompt must be a non-empty string")

        model_id = body.get("model", "")
        if not isinstance(model_id, str) or not model_id.strip():
            raise HTTPException(status_code=400, detail="model must be a non-empty string")

        model_id = model_id.strip()
        keys = _read_keys(config.data_dir)

        t0 = time.time()

        if model_id.startswith("ollama:"):
            ollama_model = model_id[len("ollama:"):]
            result = _ollama_chat(
                config.ollama_url, ollama_model, prompt.strip(),
                timeout=config.ollama_timeout,
            )
            latency_ms = int((time.time() - t0) * 1000)
            return JSONResponse({
                "output": result["content"],
                "model": model_id,
                "tokens": result.get("tokens", 0),
                "latency_ms": latency_ms,
            })

        # Cloud model
        if model_id.startswith("claude") or model_id.startswith("anthropic"):
            backend = "claude"
        elif model_id.startswith("gemini"):
            backend = "gemini"
        elif model_id.startswith("gpt") or model_id.startswith("o3") or model_id.startswith("o1"):
            backend = "gpt"
        else:
            raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")

        text, tokens = _call_cloud_api(backend, model_id, prompt.strip(), {}, keys, oauth_manager=oauth)
        latency_ms = int((time.time() - t0) * 1000)

        return JSONResponse({
            "output": text,
            "model": model_id,
            "tokens": tokens,
            "latency_ms": latency_ms,
        })

    @app.post("/v1/fan-out")
    async def v1_fan_out(request: Request) -> JSONResponse:
        """Fan-out: send the same prompt to all models concurrently, collect responses.

        Accepts:
            prompt: str        — the prompt to send to every model
            models: list[str]  — model IDs to query in parallel

        Returns:
            responses: {model_name: output}     — each model's text response
            latencies: {model_name: ms}         — per-model wall-clock latency
        """
        body = await request.json()

        prompt = body.get("prompt", "")
        if not isinstance(prompt, str) or not prompt.strip():
            raise HTTPException(status_code=400, detail="prompt must be a non-empty string")

        models = body.get("models", [])
        if not isinstance(models, list) or len(models) < 1:
            raise HTTPException(status_code=400, detail="models must be a list of at least 1 model ID")
        if not all(isinstance(m, str) and m.strip() for m in models):
            raise HTTPException(status_code=400, detail="each model must be a non-empty string")

        keys = _read_keys(config.data_dir)
        loop = asyncio.get_event_loop()

        async def _call_one(model_id: str) -> tuple[str, str, int]:
            """Call a single model and return (model_id, output, latency_ms)."""
            t0 = time.time()
            try:
                output = await loop.run_in_executor(
                    None,
                    _debate_call_model,
                    model_id,
                    prompt.strip(),
                    config,
                    keys,
                    oauth,
                )
            except HTTPException:
                raise
            except Exception as e:
                output = f"[Error: {e}]"
            latency_ms = int((time.time() - t0) * 1000)
            return model_id, output, latency_ms

        results = await asyncio.gather(*[_call_one(m) for m in models], return_exceptions=True)

        responses: dict[str, str] = {}
        latencies: dict[str, int] = {}
        for r in results:
            if isinstance(r, BaseException):
                if isinstance(r, HTTPException):
                    raise r
                raise HTTPException(status_code=500, detail=str(r))
            mid, out, lat = r
            responses[mid] = out
            latencies[mid] = lat

        return JSONResponse({
            "responses": responses,
            "latencies": latencies,
        })

    # ── GET /v1/strategies ─────────────────────────────────────────────

    @app.get("/v1/strategies")
    async def v1_strategies() -> JSONResponse:
        """Return the list of available chain strategies with metadata."""
        strategies = [
            {
                "name": "sequential",
                "description": "Models run one after another, each seeing the previous output.",
                "min_models": 2,
                "max_models": 10,
            },
            {
                "name": "fan-out",
                "description": "All models run in parallel on the same prompt, results collected.",
                "min_models": 2,
                "max_models": 10,
            },
            {
                "name": "debate",
                "description": "Models argue in rounds, refining their positions through discourse.",
                "min_models": 2,
                "max_models": 5,
            },
            {
                "name": "refine",
                "description": "Each model iteratively improves the previous model's output.",
                "min_models": 2,
                "max_models": 10,
            },
            {
                "name": "single",
                "description": "Send prompt to one model and get a direct response.",
                "min_models": 1,
                "max_models": 1,
            },
        ]
        return JSONResponse({"strategies": strategies})

    # ── GET /v1/history ────────────────────────────────────────────

    @app.get("/v1/history")
    async def v1_history(limit: int = 50) -> JSONResponse:
        """Return the last *limit* entries from ~/.brynq/chain_history.jsonl."""
        history_path = Path.home() / ".brynq" / "chain_history.jsonl"
        if not history_path.exists():
            return JSONResponse({"history": []})
        entries: list[dict] = []
        for line in history_path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line:
                continue
            try:
                entries.append(json.loads(line))
            except json.JSONDecodeError:
                continue
        return JSONResponse({"history": entries[-limit:]})

    # ── DELETE /v1/history ─────────────────────────────────────────

    @app.delete("/v1/history")
    async def v1_history_delete() -> JSONResponse:
        """Truncate ~/.brynq/chain_history.jsonl."""
        history_path = Path.home() / ".brynq" / "chain_history.jsonl"
        if history_path.exists():
            history_path.write_text("", encoding="utf-8")
        return JSONResponse({"cleared": True})

    # ── GET /v1/models/{model_id} ─────────────────────────────────────

    # Capability map per provider (static, matches cloud/planner profiles)
    _MODEL_CAPABILITIES: dict[str, list[str]] = {
        "ollama": ["text-generation", "local-inference"],
        "anthropic": ["text-generation", "analysis", "coding", "vision"],
        "google": ["text-generation", "analysis", "coding", "search"],
        "openai": ["text-generation", "analysis", "coding", "vision"],
    }

    @app.get("/v1/models/{model_id:path}")
    async def get_model_by_id(model_id: str) -> JSONResponse:
        """Look up a single model by its ID.

        Returns name, provider, capabilities, status, and average latency.
        """
        import shutil

        # Build the full models list (same logic as GET /models)
        models: list[dict[str, Any]] = []

        local_models = _ollama_list_models(config.ollama_url)
        for m in local_models:
            models.append({
                "id": f"ollama:{m['name']}",
                "name": m["name"],
                "provider": "ollama",
                "type": "local",
                "auth": "none",
            })

        keys = _read_keys(config.data_dir)

        cloud_catalog: dict[str, list[str]] = {
            "google": ["gemini-2.0-flash", "gemini-2.5-pro"],
            "anthropic": ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-3-5-20241022"],
            "openai": ["gpt-4o", "gpt-4o-mini", "o3-mini"],
        }

        google_auth = None
        if oauth is not None:
            try:
                token = oauth.get_token_sync("google")
                if token:
                    google_auth = "subscription"
            except Exception:
                pass
        if not google_auth and "google" in keys:
            google_auth = "api_key"
        if google_auth:
            for mid in cloud_catalog["google"]:
                models.append({"id": mid, "name": mid, "provider": "google", "type": "cloud", "auth": google_auth})

        claude_auth = None
        if shutil.which("claude"):
            claude_auth = "cli_subscription"
        if not claude_auth and "anthropic" in keys:
            claude_auth = "api_key"
        if claude_auth:
            for mid in cloud_catalog["anthropic"]:
                models.append({"id": mid, "name": mid, "provider": "anthropic", "type": "cloud", "auth": claude_auth})

        if "openai" in keys:
            for mid in cloud_catalog["openai"]:
                models.append({"id": mid, "name": mid, "provider": "openai", "type": "cloud", "auth": "api_key"})

        # Find the requested model
        match = None
        for m in models:
            if m["id"] == model_id:
                match = m
                break

        if match is None:
            raise HTTPException(404, f"Model '{model_id}' not found")

        provider = match["provider"]
        capabilities = _MODEL_CAPABILITIES.get(provider, ["text-generation"])
        status = "available"
        avg_latency_ms = {"ollama": 200, "anthropic": 800, "google": 600, "openai": 700}.get(provider, 500)

        return JSONResponse({
            "name": match["name"],
            "provider": provider,
            "capabilities": capabilities,
            "status": status,
            "avg_latency_ms": avg_latency_ms,
        })

    # ── GET /v1/health ─────────────────────────────────────────────

    @app.get("/v1/health")
    async def v1_health() -> JSONResponse:
        """Health check with uptime, model count, and cloud status."""
        ollama_models = _ollama_list_models(config.ollama_url)
        keys = _read_keys(config.data_dir)
        cloud_ok = cloud.health_check()
        return JSONResponse({
            "status": "ok",
            "uptime_seconds": round(time.time() - _START_TIME, 2),
            "models_available": len(ollama_models) + len(keys),
            "cloud_connected": cloud_ok,
            "version": get_version(),
        })

    return app


# ── Module-level instance for uvicorn ──────────────────────────────────

app = create_app()

if __name__ == "__main__":
    import uvicorn

    cfg = load_config()
    uvicorn.run(
        "runtime.app:app",
        host="127.0.0.1",
        port=cfg.local_port,
        reload=cfg.debug,
    )
